mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
chore(ui): improve mobile responsiveness (#1292)
This commit is contained in:
parent
c7cb02ae86
commit
d66f5e9c82
66 changed files with 549 additions and 271 deletions
|
|
@ -11,7 +11,7 @@ export function LogsOverlay({ open, onOpenChange }: LogsOverlayProps) {
|
|||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => onOpenChange(false)}>
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<PageTitle title={t('logs.title')} />
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
{t('global.loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-4xl tracking-wider">
|
||||
<div className="flex items-center text-3xl tracking-wider sm:text-4xl">
|
||||
<Balance
|
||||
onClick={() => toggleDisplayMode()}
|
||||
valueString={String(walletBalanceSummary.calculatedTotalBalanceInSats)}
|
||||
|
|
@ -76,7 +76,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-center gap-4" data-tour-id="wallet-actions">
|
||||
<div className="mt-2 flex w-full justify-center gap-2 sm:gap-4" data-tour-id="wallet-actions">
|
||||
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
|
||||
<DownloadIcon />
|
||||
{t('current_wallet.button_deposit')}
|
||||
|
|
|
|||
|
|
@ -118,15 +118,22 @@ export const CreateStepConfirm = ({
|
|||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
|
||||
<Field
|
||||
data-invalid={errors.revealSensitiveInfo !== undefined}
|
||||
orientation="horizontal"
|
||||
className="items-start"
|
||||
>
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="switch-reveal-seed"
|
||||
checked={revealSensitiveInfo}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
|
||||
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
|
||||
{t('create_wallet.confirmation_toggle_reveal_info')}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
{errors.revealSensitiveInfo?.message && (
|
||||
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>
|
||||
|
|
@ -134,15 +141,16 @@ export const CreateStepConfirm = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal">
|
||||
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal" className="items-start">
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="switch-confirm-backup"
|
||||
required={true}
|
||||
disabled={!touchedFields.revealSensitiveInfo}
|
||||
checked={backupConfirmed}
|
||||
onCheckedChange={(checked) => setValue('backupConfirmed', checked, { shouldValidate: true })}
|
||||
/>
|
||||
<FieldLabel htmlFor="switch-confirm-backup">
|
||||
<FieldLabel htmlFor="switch-confirm-backup" className="min-w-0 flex-1">
|
||||
{t('create_wallet.confirmation_toggle_info_written_down')}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
'border-brand-success/50 bg-brand-success/10': allSelected && isCorrect,
|
||||
})}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5 select-none">
|
||||
<div className="grid grid-cols-2 gap-1.5 select-none sm:grid-cols-3">
|
||||
{!isCorrect &&
|
||||
mnemonicPhrase.map((_, index) => {
|
||||
const word = selectedWords[index]
|
||||
|
|
@ -112,10 +112,13 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn('flex items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all', {
|
||||
'bg-primary/10 text-primary border-primary/20 border': isFilled,
|
||||
'border-muted bg-muted/30 border border-dashed': !isFilled,
|
||||
})}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all',
|
||||
{
|
||||
'bg-primary/10 text-primary border-primary/20 border': isFilled,
|
||||
'border-muted bg-muted/30 border border-dashed': !isFilled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('min-w-8 text-right tabular-nums', {
|
||||
|
|
@ -126,7 +129,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
{index + 1}.
|
||||
</span>
|
||||
{isFilled ? (
|
||||
<span>{isHidden ? <MaskedText masked /> : word}</span>
|
||||
<span className="min-w-0">{isHidden ? <MaskedText masked /> : word}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/30">···</span>
|
||||
)}
|
||||
|
|
@ -143,7 +146,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
</div>
|
||||
|
||||
{!allSelected && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{shuffledWords.map((word, index) => {
|
||||
const isPicked = pickedIndicesSet.has(index)
|
||||
const isWrong = wrongButtonIndex === index
|
||||
|
|
@ -154,7 +157,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
size="lg"
|
||||
variant={isWrong ? 'destructive' : isPicked ? 'outline' : 'secondary'}
|
||||
disabled={isPicked || wrongButtonIndex !== undefined}
|
||||
className={cn('font-mono text-sm transition-all', {
|
||||
className={cn('min-w-0 px-2 font-mono text-sm transition-all', {
|
||||
'pointer-events-none opacity-25': isPicked,
|
||||
'animate-shake': isWrong,
|
||||
})}
|
||||
|
|
@ -167,24 +170,21 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn('flex-1', {
|
||||
hidden: isCorrect,
|
||||
})}
|
||||
className="w-full"
|
||||
size="xxl"
|
||||
onClick={onBack}
|
||||
disabled={isCorrect || verifyMutation.isPending}
|
||||
disabled={!isCorrect || verifyMutation.isPending}
|
||||
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
{t('create_wallet.back_button')}
|
||||
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
|
||||
{t('create_wallet.confirmation_button_fund_wallet')}
|
||||
</Button>
|
||||
{skipWalletBackupVerification && !isCorrect && (
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
size="xxl"
|
||||
disabled={verifyMutation.isPending}
|
||||
|
|
@ -195,13 +195,15 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
|
|||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
size="xxl"
|
||||
disabled={!isCorrect || verifyMutation.isPending}
|
||||
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
|
||||
variant="ghost"
|
||||
className={cn({
|
||||
hidden: isCorrect,
|
||||
})}
|
||||
onClick={onBack}
|
||||
disabled={isCorrect || verifyMutation.isPending}
|
||||
>
|
||||
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
|
||||
{t('create_wallet.confirmation_button_fund_wallet')}
|
||||
<ChevronLeftIcon />
|
||||
{t('create_wallet.back_button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const CreateWalletCard = ({ icon: Icon, title, children }: PropsWithChildren<{ i
|
|||
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon className="text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">{title}</CardTitle>
|
||||
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">{children}</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export default function DevSetupPage() {
|
|||
<h5 className="text-xl font-bold">Jam Instances</h5>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<span className="font-mono">jm_regtest_joinmarket</span>
|
||||
<Badge>primary · ng native</Badge>
|
||||
</div>
|
||||
|
|
@ -57,9 +57,14 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<a
|
||||
href={LINK_JM_REGTEST_JOINMARKET2}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
|
||||
</a>
|
||||
<Badge>secondary · ng standalone</Badge>
|
||||
|
|
@ -72,8 +77,8 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<span className="font-mono">jm_regtest_joinmarket3</span>
|
||||
<Badge>tertiary · clientserver native</Badge>
|
||||
</div>
|
||||
|
|
@ -85,9 +90,14 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
<a href={LINK_JM_REGTEST_JOINMARKET4} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4 shrink-0" />
|
||||
<a
|
||||
href={LINK_JM_REGTEST_JOINMARKET4}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
jm_regtest_joinmarket4 ({LINK_JM_REGTEST_JOINMARKET4})
|
||||
</a>
|
||||
<Badge>quaternary · clientserver standalone</Badge>
|
||||
|
|
@ -115,10 +125,15 @@ export default function DevSetupPage() {
|
|||
<h5 className="text-xl font-bold">Block Explorer</h5>
|
||||
<div>
|
||||
{' '}
|
||||
<div className="flex items-center gap-2">
|
||||
<BlocksIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BlocksIcon className="h-4 w-4 shrink-0" />
|
||||
|
||||
<a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<a
|
||||
href={LINK_JM_REGTEST_EXPLORER}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -133,16 +148,23 @@ export default function DevSetupPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TerminalIcon className="h-4 w-4 shrink-0" />
|
||||
|
||||
<a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
<a
|
||||
href={LINK_JM_REGTEST_RPC_TERMINAL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 break-all underline"
|
||||
>
|
||||
Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
|
||||
</a>
|
||||
</div>
|
||||
<div className="my-2 ms-5">
|
||||
Mine a block, e.g.:
|
||||
<pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
|
||||
<pre className="max-w-full overflow-x-auto">
|
||||
generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function CreateFidelityBondDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} {...dialogProps}>
|
||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[90dvh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.create_fidelity_bond.title')}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lockdate-month" className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_month')}
|
||||
|
|
@ -186,8 +186,8 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
)}
|
||||
onClick={() => setSelectedJarIndex(jar.jarIndex)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardContent className="flex flex-col items-start justify-between gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div
|
||||
className="h-10 w-10 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: jar.color, opacity: 0.8 }}
|
||||
|
|
@ -201,7 +201,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="shrink-0 text-left sm:text-right">
|
||||
<p className="font-mono font-semibold">
|
||||
{formatSats(jar.balanceSummary.calculatedAvailableBalanceInSats)}
|
||||
</p>
|
||||
|
|
@ -324,9 +324,11 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
|
||||
{selectedUtxos.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
|
||||
<span className="font-medium">{t('earn.fidelity_bond.select_utxos.label_total_selected')}</span>
|
||||
<span className="font-mono text-lg font-bold">{formatSats(totalAmount)}</span>
|
||||
<div className="bg-muted/50 flex flex-wrap items-center justify-between gap-2 rounded-lg p-3">
|
||||
<span className="min-w-0 font-medium break-words">
|
||||
{t('earn.fidelity_bond.select_utxos.label_total_selected')}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-lg font-bold">{formatSats(totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -403,7 +405,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
|
|||
const id = useId()
|
||||
|
||||
return (
|
||||
<RadioGroup className="flex items-center justify-center" {...props}>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
|
||||
<RadioGroup className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center" {...props}>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
|
||||
<RadioGroupItem
|
||||
value={OFFERTYPE_ABS}
|
||||
id={`${id}-sw0absoffer`}
|
||||
|
|
@ -104,7 +104,7 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
|
|||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
|
||||
<RadioGroupItem
|
||||
value={OFFERTYPE_REL}
|
||||
id={`${id}-sw0reloffer`}
|
||||
|
|
|
|||
|
|
@ -358,7 +358,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
return (
|
||||
<FidelityBondCard value={it} key={it.utxo}>
|
||||
{actionsEnabled && (
|
||||
<div className="flex w-full flex-row gap-2">
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setMoveToJarUtxo(it)}>
|
||||
<UnlockIcon />
|
||||
{t('earn.fidelity_bond.existing.button_spend')}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
|
|||
t('earn.fidelity_bond.existing.title_active')
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
<CardDescription className="min-w-0 text-xs break-all">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code>{value.path}</code>
|
||||
|
|
@ -54,29 +54,29 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
|
|||
</Tooltip>
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2">
|
||||
<CoinsIcon />
|
||||
<div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<CoinsIcon className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Balance valueString={String(value.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ClockIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4">
|
||||
<ClockIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground text-sm font-semibold">
|
||||
{t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
|
||||
</span>
|
||||
<span className="text-md font-mono">
|
||||
<span className="text-md block font-mono break-words">
|
||||
{value.locktime} ({humanReadableLockDuration})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<CopyIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4">
|
||||
<CopyIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground text-sm font-semibold">
|
||||
{t('earn.fidelity_bond.existing.label_address')}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<DialogContent className="max-h-[90dvh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.move.title')}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
|
|
@ -248,8 +248,8 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
)}
|
||||
onClick={() => setSelectedJarIndex(jar.jarIndex)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardContent className="flex flex-col items-start justify-between gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<div
|
||||
className="h-10 w-10 rounded-full shadow-sm"
|
||||
style={{ backgroundColor: jar.color, opacity: 0.8 }}
|
||||
|
|
@ -274,7 +274,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_jar')}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ type Offer = NonNullable<SessionResponse['offer_list']>[number]
|
|||
const OfferTypeBadge = ({ value }: { value: Offer }) => {
|
||||
const { t } = useTranslation()
|
||||
const text = renderOfferText(value, t)
|
||||
return <Badge variant={text ? 'default' : 'outline'}>{text}</Badge>
|
||||
return (
|
||||
<Badge className="max-w-full truncate" variant={text ? 'default' : 'outline'}>
|
||||
{text}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const renderOfferText = (value: Offer, t: TFunction<'translation', undefined>) => {
|
||||
|
|
@ -52,19 +56,19 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<FingerprintIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<FingerprintIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_offer_id')}</Label>
|
||||
<span className="text-md font-mono select-all">
|
||||
<span className="text-md block font-mono break-all select-all">
|
||||
{nickname}:{value?.oid}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<HandCoinsIcon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<HandCoinsIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_cjfee')}</Label>
|
||||
<span className="text-sm">
|
||||
{isRelativeOffer(value?.ordertype || '') ? (
|
||||
|
|
@ -75,18 +79,18 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Minimize2Icon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Minimize2Icon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_minsize')}</Label>
|
||||
<span className="text-sm">
|
||||
<Balance valueString={String(value?.minsize || '0')} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Maximize2Icon />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<Maximize2Icon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_maxsize')}</Label>
|
||||
<span className="text-sm">
|
||||
<Balance valueString={String(value?.maxsize || '0')} />
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
|
||||
<DialogContent className="max-h-[90dvh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.renew.title')}</DialogTitle>
|
||||
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
|
||||
|
|
@ -256,7 +256,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="renew-lockdate-month" className="text-sm font-medium">
|
||||
{t('earn.fidelity_bond.select_date.form_label_month')}
|
||||
|
|
@ -321,7 +321,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
|
||||
{step === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
const earned24Hours = useMemo(() => sumEarned(allEntries, new Date(now - MILLISECONDS_IN_A_DAY)), [allEntries, now])
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex-1 space-y-3', className)}>
|
||||
<div className={cn('mx-auto min-w-0 flex-1 space-y-3', className)}>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner />
|
||||
|
|
@ -188,7 +188,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{(
|
||||
[
|
||||
{ label: t('earn.report.stats.earned_total'), value: earnedTotal },
|
||||
|
|
@ -212,8 +212,8 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
<EarnReportChart entries={allEntries} />
|
||||
|
||||
{/* Toolbar: search + refresh */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[12rem] flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder={t('earn.report.placeholder_search')}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const EarnReportOverlay = ({ open, onOpenChange, ...dialogProps }: EarnRe
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<PageTitle title={t('earn.report.title')} />
|
||||
|
|
|
|||
|
|
@ -32,15 +32,15 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
|
|||
|
||||
<div className="my-4">
|
||||
<h6 className="font-semibold">{t('error_page.heading_reason')}</h6>
|
||||
<Alert variant="destructive" className="overflow-scroll">
|
||||
<AlertDescription className="flex items-center justify-between">{reason}</AlertDescription>
|
||||
<Alert variant="destructive" className="overflow-x-auto">
|
||||
<AlertDescription className="min-w-0 break-words">{reason}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{stacktrace && (
|
||||
<div className="my-4">
|
||||
<h6 className="font-semibold">{t('error_page.heading_stacktrace')}</h6>
|
||||
<pre className="overflow-scroll rounded-lg border p-2">
|
||||
<pre className="max-w-full overflow-x-auto rounded-lg border p-2">
|
||||
<code>{stacktrace}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -150,15 +150,22 @@ export const ImportStepConfirm = ({
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
|
||||
<Field
|
||||
data-invalid={errors.revealSensitiveInfo !== undefined}
|
||||
orientation="horizontal"
|
||||
className="items-start"
|
||||
>
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="switch-reveal-seed"
|
||||
checked={revealSensitiveInfo}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
|
||||
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
|
||||
{t('create_wallet.confirmation_toggle_reveal_info')}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
{errors.revealSensitiveInfo?.message && (
|
||||
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ const ImportWalletCard = ({
|
|||
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon className="text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl font-bold">{title}</CardTitle>
|
||||
{description !== undefined && <CardDescription>{description}</CardDescription>}
|
||||
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
|
||||
{description !== undefined && <CardDescription className="text-center">{description}</CardDescription>}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">{children}</CardContent>
|
||||
|
|
|
|||
|
|
@ -40,21 +40,39 @@ export function AppFooter({
|
|||
<footer>
|
||||
<div className="flex flex-col items-center justify-between gap-8 px-4 py-12 sm:flex-row sm:gap-2 sm:p-4">
|
||||
<div
|
||||
className="flex flex-col justify-start gap-2 sm:flex-row sm:items-center sm:justify-center"
|
||||
className="flex w-full flex-col justify-start gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-center"
|
||||
data-tour-id="footer-tools"
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onClickCheatsheet} title={t('footer.cheatsheet')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClickCheatsheet}
|
||||
title={t('footer.cheatsheet')}
|
||||
>
|
||||
<FileQuestionMarkIcon />
|
||||
<span className="">{t('footer.cheatsheet')}</span>
|
||||
<span>{t('footer.cheatsheet')}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClickOrderbook} title={t('footer.orderbook')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClickOrderbook}
|
||||
title={t('footer.orderbook')}
|
||||
>
|
||||
<BookOpenIcon />
|
||||
<span className="">{t('footer.orderbook')}</span>
|
||||
<span>{t('footer.orderbook')}</span>
|
||||
</Button>
|
||||
{onClickLogs && (
|
||||
<Button variant="outline" size="sm" onClick={onClickLogs} title={t('footer.logs')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={onClickLogs}
|
||||
title={t('footer.logs')}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
<span className="">{t('footer.logs')}</span>
|
||||
<span>{t('footer.logs')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -84,7 +102,7 @@ export function AppFooter({
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-last flex-1 text-xs sm:order-first">
|
||||
<div className="order-last min-w-0 flex-1 text-center text-xs break-words sm:order-first sm:text-left">
|
||||
<Trans i18nKey="footer.warning">
|
||||
This is pre-alpha software.
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ const WalletPreview = ({
|
|||
const walletNameTitle = shortenStringMiddle(walletName ?? '...', 12)
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link to={routes.home} className="flex items-center gap-2" data-tour-id="wallet-preview">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<Link to={routes.home} className="flex min-w-0 items-center gap-2" data-tour-id="wallet-preview">
|
||||
<div className="flex h-8 w-8 items-center">
|
||||
{isLoading || isReloading ? (
|
||||
<Spinner className="text-muted-foreground size-6 motion-reduce:hidden" strokeWidth={3} />
|
||||
|
|
@ -47,7 +47,7 @@ const WalletPreview = ({
|
|||
<WalletIcon strokeWidth={1} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.25 leading-none">
|
||||
<div className="flex min-w-0 flex-col gap-0.25 leading-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="font-semibold tracking-tight"
|
||||
|
|
@ -57,7 +57,7 @@ const WalletPreview = ({
|
|||
</div>
|
||||
{isDevMode() && <DevBadge />}
|
||||
</div>
|
||||
<div className="flex min-h-5 min-w-[150px] items-center">
|
||||
<div className="flex min-h-5 min-w-0 items-center sm:min-w-[150px]">
|
||||
{rescanInfo?.rescanning === true ? (
|
||||
<div className="cursor-wait motion-safe:animate-pulse">
|
||||
{rescanInfo.progress !== undefined
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'
|
|||
|
||||
export const AuthPageShell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div className="from-background to-muted flex min-h-screen items-center justify-center bg-gradient-to-br p-4">
|
||||
<div className="from-background to-muted flex min-h-dvh min-h-screen items-start justify-center bg-gradient-to-br p-4 sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
|
|||
}, [])
|
||||
|
||||
return (
|
||||
<div className="bg-brand-shell text-brand-shell-foreground flex min-h-screen flex-1 flex-col transition-colors duration-300">
|
||||
<div className="bg-brand-shell text-brand-shell-foreground flex min-h-screen min-w-0 flex-1 flex-col transition-colors duration-300">
|
||||
<AppNavbar
|
||||
theme={resolvedTheme}
|
||||
isLoading={isLoading}
|
||||
|
|
@ -87,7 +87,7 @@ export function LayoutInner({ onLogout, onLockWallet, children }: LayoutInnerPro
|
|||
sessionInfo={jmSession}
|
||||
sidebarInfo={sidebarContext}
|
||||
/>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
<AppFooter
|
||||
websocketInfo={websocket}
|
||||
blockHeight={jmSession?.block_height}
|
||||
|
|
|
|||
|
|
@ -181,7 +181,10 @@ export const PostLoginOnboardingTour = ({ enabled = true }: PostLoginOnboardingT
|
|||
/>
|
||||
)}
|
||||
|
||||
<Card className={cn('absolute max-w-[calc(100vw-2rem)] shadow-2xl')} style={tooltipStyles}>
|
||||
<Card
|
||||
className={cn('absolute max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] overflow-y-auto shadow-2xl')}
|
||||
style={tooltipStyles}
|
||||
>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-lg">{currentStep.title}</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -191,11 +194,11 @@ export const PostLoginOnboardingTour = ({ enabled = true }: PostLoginOnboardingT
|
|||
<CardContent>
|
||||
<p className="text-sm leading-relaxed">{currentStep.description}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={closeTour}>
|
||||
<CardFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="ghost" size="sm" className="w-full sm:w-auto" onClick={closeTour}>
|
||||
Skip tour
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
|
|||
<CardHeader className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="font-mono break-all select-all">{fileName}</CardTitle>
|
||||
{/* Search + action buttons */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="relative max-w-[360px] min-w-[220px] flex-1">
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-[360px] sm:min-w-[220px]">
|
||||
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={searchValue}
|
||||
|
|
@ -177,7 +177,7 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
|
|||
title={t('global.download')}
|
||||
>
|
||||
<DownloadIcon className="group/download" />
|
||||
{t('global.download')}
|
||||
<span className="hidden sm:inline">{t('global.download')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -186,7 +186,7 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
|
|||
title={t('global.refresh')}
|
||||
>
|
||||
<RefreshCwIcon className={cn({ 'animate-spin': isLoadingRefresh })} />
|
||||
{t('global.refresh')}
|
||||
<span className="hidden sm:inline">{t('global.refresh')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
|
|||
|
|
@ -88,13 +88,13 @@ export const LoginCard = ({
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('login.title')}</CardTitle>
|
||||
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
|
||||
{listWalletsLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</>
|
||||
) : wallets && wallets.length > 0 ? (
|
||||
<CardDescription>{t('login.subtitle')}</CardDescription>
|
||||
<CardDescription className="text-center">{t('login.subtitle')}</CardDescription>
|
||||
) : undefined}
|
||||
{enableOnboardingDialog ? (
|
||||
<Button variant="link" size="sm" className="h-auto px-0" onClick={() => setShowOnboarding(true)}>
|
||||
|
|
|
|||
|
|
@ -116,10 +116,10 @@ export const LoginFormComponent = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wallets?.map((wallet) => (
|
||||
<SelectItem key={wallet} value={wallet} className="text-base">
|
||||
{shortenStringMiddle(walletDisplayName(wallet), 32)}
|
||||
<SelectItem key={wallet} value={wallet} className="min-w-0 text-base">
|
||||
<span className="min-w-0 flex-1 truncate">{shortenStringMiddle(walletDisplayName(wallet), 32)}</span>
|
||||
{activeWallet === wallet ? (
|
||||
<span className="text-muted-foreground/50 inline-flex items-center gap-1.5 py-1 text-xs">
|
||||
<span className="text-muted-foreground/50 inline-flex shrink-0 items-center gap-1.5 py-1 text-xs">
|
||||
<ActivityIndicator active={makerRunning || coinjoinInProgress} />
|
||||
{t('wallets.wallet_preview.wallet_active')}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ export const OnboardingDialog = ({ open, onOpenChange }: OnboardingDialogProps)
|
|||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<Badge variant="destructive">{t('onboarding.splashscreen_warning_title')}</Badge>
|
||||
<Badge className="max-w-full whitespace-normal" variant="destructive">
|
||||
{t('onboarding.splashscreen_warning_title')}
|
||||
</Badge>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
<Trans
|
||||
i18nKey="onboarding.splashscreen_warning_text"
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('mx-auto space-y-3', className)}>
|
||||
<div className={cn('w-full min-w-0 space-y-3', className)}>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircleIcon />
|
||||
|
|
@ -262,9 +262,9 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex-1 space-y-3', className)}>
|
||||
<div className="flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className={cn('flex min-h-0 w-full min-w-0 flex-1 flex-col space-y-3', className)}>
|
||||
<div className="flex w-full flex-col items-start justify-center gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{searchInputRaw === ''
|
||||
? t('orderbook.text_orderbook_summary', summary)
|
||||
|
|
@ -272,36 +272,36 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex w-full min-w-0 flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center">
|
||||
{showDemoButton && (
|
||||
<Button
|
||||
className="text-xs"
|
||||
className="w-full min-w-0 justify-center overflow-hidden text-xs sm:w-auto"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={__dev_generateDemoReportEntryButton}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
Add Demo Entry
|
||||
<DevBadge />
|
||||
<span className="truncate">Add Demo Entry</span>
|
||||
<DevBadge className="shrink-0" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ButtonGroup>
|
||||
<ButtonGroup className="w-full min-w-0 sm:w-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-r-none"
|
||||
className="min-w-0 flex-1 justify-center rounded-r-none sm:flex-none"
|
||||
size="sm"
|
||||
onClick={() => void handleReload()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
|
||||
{t('orderbook.button_reload_title')}
|
||||
<span className="truncate">{t('orderbook.button_reload_title')}</span>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={isFetching}>
|
||||
<Button className="shrink-0" variant="outline" size="sm" disabled={isFetching}>
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -319,7 +319,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
placeholder={t('orderbook.placeholder_search')}
|
||||
value={searchInputRaw}
|
||||
onChange={(event) => setSearchInputRaw(event.target.value)}
|
||||
className="w-64"
|
||||
className="w-full min-w-0 sm:w-64"
|
||||
disabled={isFetching}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -328,14 +328,15 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
{/* Controls */}
|
||||
{nickname && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="highlight-my-offers"
|
||||
checked={isHighlightMyOffers}
|
||||
onCheckedChange={(checked) => setHighlightMyOffers(checked)}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
<Label htmlFor="highlight-my-offers" className="flex flex-col items-start gap-0">
|
||||
<Label htmlFor="highlight-my-offers" className="min-w-0 flex-1 flex-col items-start gap-0">
|
||||
<div className="font-medium">{t('orderbook.label_highlight_own_orders')}</div>
|
||||
{myOffers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">{t('orderbook.text_highlight_own_orders_subtitle')}</div>
|
||||
|
|
@ -344,8 +345,9 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
</div>
|
||||
|
||||
{myOffers.length > 0 && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<Switch
|
||||
className="mt-0.5 shrink-0"
|
||||
id="pin-my-offers"
|
||||
checked={isPinMyOffers}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -356,7 +358,7 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
}}
|
||||
disabled={isFetching}
|
||||
/>
|
||||
<Label htmlFor="pin-my-offers" className="flex flex-col items-start gap-0">
|
||||
<Label htmlFor="pin-my-offers" className="min-w-0 flex-1 flex-col items-start gap-0">
|
||||
<div className="font-medium">{t('orderbook.label_pin_to_top_own_orders')}</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t('orderbook.text_pin_to_top_own_orders_subtitle')}
|
||||
|
|
@ -368,12 +370,12 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
)}
|
||||
|
||||
{marketSummary && (
|
||||
<Card>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('orderbook.market_summary_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Tabs defaultValue="summary">
|
||||
<Tabs defaultValue="summary" className="min-w-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="summary" className="cursor-pointer">
|
||||
{t('orderbook.tab_summary')}
|
||||
|
|
@ -383,10 +385,10 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="summary">
|
||||
<TabsContent value="summary" className="min-w-0 overflow-hidden">
|
||||
<div className="text-muted-foreground grid gap-1 text-sm sm:grid-cols-2">
|
||||
{marketSummary.medianAbsFee !== null && (
|
||||
<p className="flex items-center gap-1">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{t('orderbook.market_summary_median_abs_fee_label')}:</span>
|
||||
<Balance
|
||||
valueString={String(Math.round(marketSummary.medianAbsFee))}
|
||||
|
|
@ -395,23 +397,23 @@ export const OrderbookContent = ({ enabled, className }: OrderbookContentProps)
|
|||
</p>
|
||||
)}
|
||||
{marketSummary.medianRelFee !== null && (
|
||||
<p>
|
||||
<p className="break-words">
|
||||
{t('orderbook.market_summary_median_rel_fee', {
|
||||
value: factorToPercentage(marketSummary.medianRelFee).toFixed(4),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="flex items-center gap-1">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{t('orderbook.market_summary_total_liquidity_label')}:</span>
|
||||
<Balance valueString={String(marketSummary.totalLiquidity)} enableVisibilityToggle={false} />
|
||||
</p>
|
||||
<p className="flex items-center gap-1">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{t('orderbook.market_summary_offer_min_size_label')}:</span>
|
||||
<Balance valueString={String(marketSummary.minOfferSize)} enableVisibilityToggle={false} />
|
||||
<span>–</span>
|
||||
<Balance valueString={String(marketSummary.maxOfferSize)} enableVisibilityToggle={false} />
|
||||
</p>
|
||||
<p>
|
||||
<p className="break-words">
|
||||
{t('orderbook.market_summary_bonded_makers', {
|
||||
bonded: marketSummary.bondedMakers,
|
||||
unbonded: marketSummary.unbondedMakers,
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ export function OrderbookOverlay({ open, onOpenChange, ...dialogProps }: Orderbo
|
|||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => onOpenChange(false)} {...dialogProps}>
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
|
||||
<DialogHeader className="px-2">
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! w-screen max-w-none! flex-col gap-0! overflow-hidden rounded-none border-none p-0!">
|
||||
<DialogHeader className="px-4 py-3 sm:px-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<PageTitle title={t('orderbook.title')} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<OrderbookContent enabled={open} className="flex h-full flex-col p-2 pt-0" />
|
||||
<div className="flex min-h-0 w-full flex-1 overflow-hidden px-2 pb-2 sm:px-4 sm:pb-4">
|
||||
<OrderbookContent enabled={open} className="flex min-h-0 w-full flex-1 flex-col" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -138,6 +138,8 @@ describe('OrderbookTable', () => {
|
|||
render(<OrderbookTable tableEntries={[relativeEntry, absoluteEntry]} selectedEntries={[]} pinnedEntries={[]} />)
|
||||
|
||||
for (const heading of [
|
||||
'orderbook.table.heading_counterparty',
|
||||
'orderbook.table.heading_type',
|
||||
'orderbook.table.heading_fee',
|
||||
'orderbook.table.heading_minimum_size',
|
||||
'orderbook.table.heading_maximum_size',
|
||||
|
|
@ -147,6 +149,7 @@ describe('OrderbookTable', () => {
|
|||
const header = screen.queryByText(heading)
|
||||
if (header) {
|
||||
await user.click(header)
|
||||
await user.click(header)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,6 +157,52 @@ describe('OrderbookTable', () => {
|
|||
expect(screen.getByText('orderbook.table.heading_counterparty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the normal scrollable table on mobile-sized layouts', async () => {
|
||||
const user = userEvent.setup()
|
||||
const firstEntry = makeEntry({
|
||||
bondValue: {
|
||||
amount: 0,
|
||||
displayValue: '7',
|
||||
value: 7,
|
||||
},
|
||||
counterparty: 'same-maker',
|
||||
fee: {
|
||||
displayValue: '100',
|
||||
value: 100,
|
||||
},
|
||||
orderId: '2',
|
||||
})
|
||||
const secondEntry = makeEntry({
|
||||
counterparty: 'same-maker',
|
||||
fee: {
|
||||
displayValue: '900',
|
||||
value: 900,
|
||||
},
|
||||
orderId: '1',
|
||||
})
|
||||
|
||||
render(<OrderbookTable tableEntries={[firstEntry, secondEntry]} selectedEntries={[]} pinnedEntries={[]} />)
|
||||
|
||||
expect(screen.getByRole('table')).toHaveClass('min-w-[42rem]')
|
||||
expect(screen.getByText('orderbook.table.heading_minimum_size')).toBeInTheDocument()
|
||||
expect(screen.getByText('orderbook.table.heading_maximum_size')).toBeInTheDocument()
|
||||
expect(screen.getByText('orderbook.table.heading_bond_value')).toBeInTheDocument()
|
||||
expect(screen.queryByText('orderbook.table.heading_miner_fee_contribution')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('orderbook.table.heading_counterparty'))
|
||||
await user.click(screen.getByText('orderbook.table.heading_fee'))
|
||||
await user.click(screen.getByText('orderbook.table.heading_fee'))
|
||||
})
|
||||
|
||||
it('handles showing all rows when the filtered table is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<OrderbookTable tableEntries={[]} selectedEntries={[]} pinnedEntries={[]} />)
|
||||
|
||||
await user.click(screen.getByText('show-all'))
|
||||
await waitFor(() => expect(screen.getByText('pagination:1/0:-1:0')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('filters rows and handles empty pinned row models', () => {
|
||||
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined)
|
||||
|
||||
|
|
|
|||
|
|
@ -214,9 +214,7 @@ export const OrderbookTable = ({
|
|||
],
|
||||
[t],
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
minerFeeContribution: false,
|
||||
})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ minerFeeContribution: false })
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [rowPinning, setRowPinning] = useState<RowPinningState>({
|
||||
top: [],
|
||||
|
|
@ -290,9 +288,9 @@ export const OrderbookTable = ({
|
|||
}, [table, onChange])
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-lg">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden rounded-lg border shadow-lg">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table className="min-w-[42rem] text-sm">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
|
|||
<div className="space-y-2">
|
||||
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
|
||||
<FieldLabel>{t('receive.label_source_jar')}</FieldLabel>
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{jars.map((jar, index) => (
|
||||
<SelectableJar
|
||||
key={index}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
<Card>
|
||||
<CardContent className="flex w-full flex-col items-center justify-center gap-2">
|
||||
{getAddressMutation.isPending ? (
|
||||
<Skeleton style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }} />
|
||||
<Skeleton className="aspect-square w-full max-w-80" />
|
||||
) : getAddressMutation.data?.address ? (
|
||||
<BitcoinAddressQrCode
|
||||
className="animate-in fade-in duration-1000"
|
||||
|
|
@ -128,10 +128,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
width={QRCODE_WIDTH}
|
||||
/>
|
||||
) : getAddressMutation.isIdle ? (
|
||||
<div
|
||||
className={cn('flex items-center justify-center border')}
|
||||
style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }}
|
||||
>
|
||||
<div className={cn('flex aspect-square w-full max-w-80 items-center justify-center border')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
|
|
@ -148,8 +145,9 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn('text-destructive flex items-center justify-center border text-sm')}
|
||||
style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }}
|
||||
className={cn(
|
||||
'text-destructive flex aspect-square w-full max-w-80 items-center justify-center border text-sm',
|
||||
)}
|
||||
>
|
||||
{t('receive.error_loading_address_failed')}
|
||||
</div>
|
||||
|
|
@ -157,7 +155,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
|
||||
{getAddressMutation.isPending ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Skeleton className="mt-0.5 h-5 w-[24rem]" />
|
||||
<Skeleton className="mt-0.5 h-5 w-full max-w-96" />
|
||||
<Skeleton className="h-6 w-[84px]" />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -181,7 +179,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export default function JarSelectorDialog({
|
|||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{jars.map((jar, index) => (
|
||||
<SelectableJar
|
||||
key={index}
|
||||
|
|
@ -81,11 +81,16 @@ export default function JarSelectorDialog({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button className="flex-1" variant="outline" onClick={handleClose}>
|
||||
<DialogFooter className="gap-3 sm:justify-center">
|
||||
<Button className="min-h-12 flex-1 text-base" variant="outline" size="xxl" onClick={handleClose}>
|
||||
{t('modal.confirm_button_reject')}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => void confirm()} disabled={!selectedJar || isConfirming}>
|
||||
<Button
|
||||
className="min-h-12 flex-1 text-base"
|
||||
size="xxl"
|
||||
onClick={() => void confirm()}
|
||||
disabled={!selectedJar || isConfirming}
|
||||
>
|
||||
{isConfirming ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export default function PaymentConfirmDialog({
|
|||
<Dialog open={open} onOpenChange={handleClose} {...dialogProps}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-center gap-2 font-semibold">
|
||||
<DialogTitle className="flex flex-wrap items-center justify-center gap-2 text-center font-semibold">
|
||||
{t('send.confirm_send_modal.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
|
|
@ -106,7 +106,7 @@ export default function PaymentConfirmDialog({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 space-y-1 space-x-4 md:grid-cols-5">
|
||||
<div className="grid grid-cols-1 gap-x-4 gap-y-1 md:grid-cols-5">
|
||||
<div className="col-span-1 font-semibold md:text-right">{t('send.confirm_send_modal.label_source_jar')}</div>
|
||||
<div className="col-span-4">
|
||||
{meta.sourceJar.name} <span className="text-muted-foreground text-xs">#{meta.sourceJar.jarIndex}</span>
|
||||
|
|
@ -132,7 +132,7 @@ export default function PaymentConfirmDialog({
|
|||
</div>
|
||||
|
||||
<div className="col-span-1 font-semibold md:text-right">{t('send.confirm_send_modal.label_amount')}</div>
|
||||
<div className="col-span-4 flex items-center gap-1">
|
||||
<div className="col-span-4 flex flex-wrap items-center gap-1">
|
||||
{values.amount?.isSweep ? (
|
||||
<>
|
||||
<Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
|
||||
|
|
@ -165,7 +165,7 @@ export default function PaymentConfirmDialog({
|
|||
{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}
|
||||
</div>
|
||||
|
||||
<div className="col-span-4 flex items-center gap-1">
|
||||
<div className="col-span-4 flex flex-wrap items-center gap-1">
|
||||
≤
|
||||
<Balance valueString={String(estimatedMaxCollaboratorFee.maxFee)} showBalance={true} />
|
||||
<span className="text-muted-foreground text-xs">
|
||||
|
|
@ -211,11 +211,16 @@ export default function PaymentConfirmDialog({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button className="flex-1" variant="outline" onClick={handleClose}>
|
||||
<DialogFooter className="gap-3 sm:justify-center">
|
||||
<Button className="min-h-12 flex-1 text-base" variant="outline" size="xxl" onClick={handleClose}>
|
||||
{t('modal.confirm_button_reject')}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => void confirm()} disabled={isConfirming}>
|
||||
<Button
|
||||
className="min-h-12 flex-1 text-base"
|
||||
size="xxl"
|
||||
onClick={() => void confirm()}
|
||||
disabled={isConfirming}
|
||||
>
|
||||
{isConfirming ? (
|
||||
<>
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
|
|
|
|||
|
|
@ -172,6 +172,9 @@ describe('SendForm', () => {
|
|||
|
||||
fireEvent.click(document.querySelector('#btn-sweep-trigger')!)
|
||||
expect(document.querySelector('#btn-sweep-clear-trigger')).toBeInTheDocument()
|
||||
expect(
|
||||
document.querySelector('#send-amount-sweep-from-jar')?.closest('[data-slot="button-group"]'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
// reselect the same jar while sweep is active -> hits the sweep-reset branch
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Jar 0' }))
|
||||
|
|
@ -194,6 +197,9 @@ describe('SendForm', () => {
|
|||
fireEvent.click(document.querySelector('#show-address-from-jar-selector-trigger')!)
|
||||
fireEvent.click(await screen.findByTestId('jar-confirm'))
|
||||
await waitFor(() => expect(screen.getByTestId('address')).toBeInTheDocument())
|
||||
expect(
|
||||
document.querySelector('#send-destination-address-from-jar')?.closest('[data-slot="button-group"]'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an error when the jar selector address lookup fails', async () => {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ type AddressFromJarSelectorDialog = Omit<ComponentProps<typeof JarSelectorDialog
|
|||
onConfirm: (jar: JarIndex, address: AddressInfo) => void
|
||||
}
|
||||
|
||||
const responsiveSelectionButtonGroupClass =
|
||||
'w-full flex-col gap-2 sm:flex-row sm:items-stretch [&>*]:rounded-md [&>*]:border sm:[&>*:not(:first-child)]:rounded-l-none sm:[&>*:not(:first-child)]:border-l-0 sm:[&>*:not(:last-child)]:rounded-r-none'
|
||||
|
||||
const AddressFromJarSelectorDialog = ({
|
||||
walletFileName,
|
||||
onError,
|
||||
|
|
@ -299,11 +302,11 @@ export function SendForm({
|
|||
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
|
||||
<div className="space-y-2">
|
||||
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
|
||||
{sourceJarLabelButton && <>{sourceJarLabelButton}</>}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{jars.map((jar, index) => (
|
||||
<SelectableJar
|
||||
key={index}
|
||||
|
|
@ -351,7 +354,7 @@ export function SendForm({
|
|||
)}
|
||||
</FieldLabel>
|
||||
<ButtonGroup
|
||||
className={cn({
|
||||
className={cn('w-full', {
|
||||
hidden: destinationJar !== undefined,
|
||||
})}
|
||||
>
|
||||
|
|
@ -372,6 +375,7 @@ export function SendForm({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => setShowQrScannerDialog(true)}
|
||||
>
|
||||
|
|
@ -383,6 +387,7 @@ export function SendForm({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => setShowAddressFromJarSelectorDialog(true)}
|
||||
>
|
||||
|
|
@ -391,7 +396,7 @@ export function SendForm({
|
|||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup
|
||||
className={cn({
|
||||
className={cn(responsiveSelectionButtonGroupClass, {
|
||||
hidden: destinationJar === undefined,
|
||||
})}
|
||||
>
|
||||
|
|
@ -401,12 +406,12 @@ export function SendForm({
|
|||
id="send-destination-address-from-jar"
|
||||
className={cn(
|
||||
inputVariants(),
|
||||
'flex items-center justify-between gap-2',
|
||||
'flex min-h-8 min-w-0 flex-col items-start justify-center gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2',
|
||||
'bg-input/50 dark:bg-input/80 h-auto',
|
||||
)}
|
||||
>
|
||||
<Address value={values.destination.address} copyable={true} />
|
||||
<Badge className="text-sm" variant="default">
|
||||
<Address value={values.destination.address} copyable={true} className="text-xs sm:text-sm" />
|
||||
<Badge className="shrink-0 text-sm" variant="default">
|
||||
{destinationJar.name} <span className="text-xs">#{destinationJar.jarIndex}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -416,7 +421,7 @@ export function SendForm({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-auto"
|
||||
className="h-auto w-full sm:w-auto"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setValue('destination.address', '', { shouldValidate: true })
|
||||
|
|
@ -447,7 +452,7 @@ export function SendForm({
|
|||
<FieldLabel htmlFor="send-amount">{t('send.label_amount_input')}</FieldLabel>
|
||||
|
||||
<ButtonGroup
|
||||
className={cn('relative', {
|
||||
className={cn('relative w-full', {
|
||||
hidden: isSweep === true,
|
||||
})}
|
||||
>
|
||||
|
|
@ -471,6 +476,7 @@ export function SendForm({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="shrink-0"
|
||||
disabled={
|
||||
disabled ||
|
||||
sourceJar === undefined ||
|
||||
|
|
@ -489,7 +495,7 @@ export function SendForm({
|
|||
</ButtonGroup>
|
||||
|
||||
<ButtonGroup
|
||||
className={cn({
|
||||
className={cn(responsiveSelectionButtonGroupClass, {
|
||||
hidden: isSweep !== true,
|
||||
})}
|
||||
>
|
||||
|
|
@ -497,7 +503,7 @@ export function SendForm({
|
|||
id="send-amount-sweep-from-jar"
|
||||
className={cn(
|
||||
inputVariants(),
|
||||
'flex items-center justify-between gap-2',
|
||||
'flex min-h-8 min-w-0 flex-col items-start justify-center gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2',
|
||||
'bg-input/50 dark:bg-input/80 h-auto',
|
||||
)}
|
||||
aria-disabled
|
||||
|
|
@ -506,7 +512,7 @@ export function SendForm({
|
|||
<Balance valueString={values.amount.sweepAmount.toFixed(0)} />
|
||||
)}
|
||||
|
||||
<Badge className="text-sm" variant="default">
|
||||
<Badge className="shrink-0 text-sm" variant="default">
|
||||
{sourceJar?.name} <span className="text-xs">#{sourceJar?.jarIndex}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -516,7 +522,7 @@ export function SendForm({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-auto"
|
||||
className="h-auto w-full sm:w-auto"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setValue('amount.isSweep', false, { shouldValidate: true })
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
{currentPaymentAttempt && (
|
||||
<pre>
|
||||
<pre className="max-w-full overflow-x-auto">
|
||||
{JSON.stringify(
|
||||
{
|
||||
sourceJar: currentPaymentAttempt.data.source.fromJar,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ const TxFeeUnitInput = (props: React.ComponentProps<typeof RadioGroup>) => {
|
|||
const id = useId()
|
||||
|
||||
return (
|
||||
<RadioGroup className="flex items-center justify-center" {...props}>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
|
||||
<RadioGroup className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center" {...props}>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
|
||||
<RadioGroupItem
|
||||
value={TX_FEE_UNITS.BLOCKS}
|
||||
id={`${id}-blocks`}
|
||||
|
|
@ -38,7 +38,7 @@ const TxFeeUnitInput = (props: React.ComponentProps<typeof RadioGroup>) => {
|
|||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
|
||||
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
|
||||
<RadioGroupItem
|
||||
value={TX_FEE_UNITS.SATS_PER_VBYTE}
|
||||
id={`${id}-satsperkvb`}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const UtxoSelectionDialog = ({
|
|||
placeholder={t('jar_details.utxo_list.placeholder_search')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<div className="max-h-[55vh] overflow-hidden">
|
||||
<div className="max-h-[55dvh] min-h-0 overflow-hidden">
|
||||
<JarUtxosTable
|
||||
globalFilter={filter}
|
||||
tableEntries={tableEntries}
|
||||
|
|
|
|||
|
|
@ -143,10 +143,7 @@ const AccountXpubItem = ({ xpub, accountNameAndLabel }: AccountXpubItemProps) =>
|
|||
{showQrCode ? (
|
||||
<BitcoinXpubQrCode xpub={xpub.xpub} width={QRCODE_WIDTH} className="animate-in fade-in duration-1000" />
|
||||
) : (
|
||||
<div
|
||||
className={cn('flex items-center justify-center border')}
|
||||
style={{ height: QRCODE_WIDTH, width: QRCODE_WIDTH }}
|
||||
>
|
||||
<div className={cn('flex aspect-square w-full max-w-80 items-center justify-center border')}>
|
||||
<Button variant="outline" size="lg" onClick={() => setShowQrCode(true)}>
|
||||
<HatGlassesIcon />
|
||||
{
|
||||
|
|
@ -175,15 +172,15 @@ export const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) =>
|
|||
})
|
||||
return (
|
||||
<AccordionItem key={index} value={String(account.accountIndex)}>
|
||||
<AccordionTrigger className="group/xpub-accordion-trigger px-4 no-underline!">
|
||||
<span className="flex-1 flex-col group-hover/xpub-accordion-trigger:underline">
|
||||
<AccordionTrigger className="group/xpub-accordion-trigger flex-wrap px-4 no-underline!">
|
||||
<span className="min-w-0 flex-1 flex-col break-words group-hover/xpub-accordion-trigger:underline">
|
||||
{account.accountName}
|
||||
</span>
|
||||
<span className="text-muted-foreground font-mono">{accountLabel}</span>
|
||||
<span className="text-muted-foreground shrink-0 font-mono">{accountLabel}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 p-4 pt-2">
|
||||
<Label className="text-muted-foreground text-sm">
|
||||
<div className="flex flex-1 items-end gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-end gap-2">
|
||||
{/* TODO: i18n */}Extended Public Key
|
||||
<span className="text-muted-foreground/70 font-mono text-xs">{account.path}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ export const LanguageSelector = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/50 flex h-7 w-7 items-center justify-center rounded-lg border">
|
||||
<div className="flex flex-col items-stretch justify-between gap-2 py-2 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
|
||||
<GlobeIcon className="text-muted-foreground h-3 w-3" />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -27,7 +27,7 @@ export const LanguageSelector = () => {
|
|||
</div>
|
||||
|
||||
<Select value={i18n.resolvedLanguage} onValueChange={(value) => void handleLanguageChange(value)}>
|
||||
<SelectTrigger className="h-7 w-38 text-xs" aria-label="Select language">
|
||||
<SelectTrigger className="h-7 w-full text-xs sm:w-38" aria-label="Select language">
|
||||
<SelectValue placeholder={getCurrentLanguageDescription()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -156,8 +156,13 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
|
|||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-3 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => void navigate(routes.settings)} title={t('global.back')}>
|
||||
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={() => void navigate(routes.settings)}
|
||||
title={t('global.back')}
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
<span className="sr-only">{t('global.back')}</span>
|
||||
</Button>
|
||||
|
|
@ -173,9 +178,9 @@ export const RescanChainPage = ({ walletFileName }: RescanChainProps) => {
|
|||
/>
|
||||
{rescanInfo.rescanning && (
|
||||
<div className="bg-muted/50 mt-4 animate-pulse rounded-lg p-3 duration-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCwIcon className="h-4 w-4 animate-spin motion-reduce:hidden" />
|
||||
<span className="text-sm">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<RefreshCwIcon className="mt-0.5 h-4 w-4 shrink-0 animate-spin motion-reduce:hidden" />
|
||||
<span className="min-w-0 text-sm break-words">
|
||||
{rescanInfo?.progress === undefined
|
||||
? t('app.alert_rescan_in_progress')
|
||||
: t('app.alert_rescan_in_progress_with_progress', {
|
||||
|
|
|
|||
|
|
@ -23,22 +23,22 @@ export const SettingItem = ({
|
|||
}: SettingsItemProps) => {
|
||||
const content = (
|
||||
<div
|
||||
className={cx('flex items-center justify-between py-2', {
|
||||
className={cx('flex min-w-0 items-center justify-between gap-2 py-2', {
|
||||
'hover:bg-muted/50 -mx-2 cursor-pointer rounded-md px-2': !disabled,
|
||||
'cursor-not-allowed opacity-60': disabled,
|
||||
})}
|
||||
onClick={!disabled && action ? () => void action() : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted/50 flex h-7 w-7 items-center justify-center rounded-lg border">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4 align-middle" />}
|
||||
{renderIcon?.({ className: 'text-muted-foreground h-4 w-4 align-middle' })}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium break-words">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{children !== undefined && <div className="shrink-0">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ export const FeeConfigDialog = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.fees.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -386,12 +386,12 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
|
||||
<Card>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="bg-muted/50 flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div>
|
||||
<div className="bg-muted/50 flex flex-col items-start justify-between gap-2 rounded-lg border px-4 py-3 sm:flex-row sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">{t('scheduler.complete_wallet_title')}</div>
|
||||
<div className="text-muted-foreground text-sm">{t('scheduler.complete_wallet_subtitle')}</div>
|
||||
</div>
|
||||
<div className="font-semibold">
|
||||
<div className="shrink-0 font-semibold">
|
||||
<Balance valueString={String(walletInfo.walletBalanceSummary.calculatedAvailableBalanceInSats)} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState, type ComponentProps } from 'react'
|
||||
import { Html5Qrcode } from 'html5-qrcode'
|
||||
import { Html5Qrcode, type Html5QrcodeCameraScanConfig } from 'html5-qrcode'
|
||||
import { ClipboardPasteIcon, ImageUpIcon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -16,7 +16,13 @@ import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21'
|
|||
import type { WithRequiredProperty } from '@/types/global'
|
||||
|
||||
const QR_READER_ELEMENT_ID = 'qr-scanner-reader'
|
||||
const SCAN_CONFIG = { fps: 10, qrbox: { width: 250, height: 250 } }
|
||||
const SCAN_CONFIG = {
|
||||
fps: 10,
|
||||
qrbox: (viewfinderWidth, viewfinderHeight) => {
|
||||
const edgeLength = Math.max(1, Math.floor(Math.min(250, viewfinderWidth * 0.8, viewfinderHeight * 0.8)))
|
||||
return { width: edgeLength, height: edgeLength }
|
||||
},
|
||||
} satisfies Html5QrcodeCameraScanConfig
|
||||
|
||||
type QrScannerDialogProps = WithRequiredProperty<
|
||||
Omit<ComponentProps<typeof Dialog>, 'children'>,
|
||||
|
|
@ -178,7 +184,7 @@ export default function QrScannerDialog({ open, onOpenChange, onScan, ...dialogP
|
|||
}}
|
||||
/>
|
||||
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<DialogFooter className="sm:justify-center [&>[data-slot=button]]:min-h-10">
|
||||
<Button className="flex-1" variant="outline" onClick={() => void handlePaste()}>
|
||||
<ClipboardPasteIcon />
|
||||
{t('send.qr_paste_button')}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function AccordionTrigger({ className, children, ...props }: React.ComponentProp
|
|||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 flex min-w-0 flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
'[&_a]:hover:text-foreground font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
|
||||
'[&_a]:hover:text-foreground min-w-0 font-medium break-words group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -44,7 +44,7 @@ function AlertDescription({ className, ...props }: React.ComponentProps<'div'>)
|
|||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground [&_a]:hover:text-foreground text-sm text-balance md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
|
||||
'text-muted-foreground [&_a]:hover:text-foreground min-w-0 text-sm text-balance break-words md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Separator } from '@/components/ui/separator'
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
"flex w-fit max-w-full items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:min-w-0 [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex min-w-0 flex-col gap-4 rounded-xl border py-4 shadow-sm sm:gap-6 sm:py-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -16,7 +19,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-0 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
'@container/card-header grid min-w-0 auto-rows-min grid-rows-[auto_auto] items-start gap-0 px-4 has-data-[slot=card-action]:grid-cols-1 sm:px-6 sm:has-data-[slot=card-action]:grid-cols-[minmax(0,1fr)_auto] [.border-b]:pb-4 sm:[.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,18 +39,27 @@ function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
|||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
className={cn(
|
||||
'col-start-1 row-start-3 mt-3 max-w-full min-w-0 self-start justify-self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:mt-0 sm:justify-self-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||
return <div data-slot="card-content" className={cn('min-w-0 px-4 sm:px-6', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-footer" className={cn('flex items-center px-6 [.border-t]:pt-6', className)} {...props} />
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex min-w-0 items-center px-4 sm:px-6 [.border-t]:pt-4 sm:[.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ function DialogContent({
|
|||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-2xl',
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-4 shadow-lg duration-200 sm:max-w-2xl sm:p-6',
|
||||
className,
|
||||
)}
|
||||
onPointerDownOutside={(event) => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ type PlainAddressProps = {
|
|||
const PlainAddress = ({ value, className, chunked }: PlainAddressProps) => {
|
||||
const chunks = chunked ? value.match(/.{1,4}/g) : [value]
|
||||
return (
|
||||
<span className={cn(styles.bitcoinAddress, chunked === true ? styles.chunked : undefined, className)}>
|
||||
<span className={cn('min-w-0', styles.bitcoinAddress, chunked === true ? styles.chunked : undefined, className)}>
|
||||
{chunks?.map((it, index) => (
|
||||
<span key={index}>{it}</span>
|
||||
))}
|
||||
|
|
@ -30,7 +30,7 @@ type CopyableAddressProps = PlainAddressProps
|
|||
const CopyableAddress = (props: CopyableAddressProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<PlainAddress {...props} />
|
||||
<CopyButton
|
||||
value={props.value}
|
||||
|
|
|
|||
|
|
@ -52,13 +52,13 @@ const QrCodeComponent = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn('group/qrcode relative flex items-center justify-center', className)}
|
||||
style={{ height: width, width: width }}
|
||||
className={cn('group/qrcode relative flex aspect-square w-full items-center justify-center', className)}
|
||||
style={{ maxWidth: width }}
|
||||
>
|
||||
{imageDataUrl && (
|
||||
<>
|
||||
<img
|
||||
className={cn('transition-all duration-500', {
|
||||
className={cn('h-full w-full transition-all duration-500', {
|
||||
'group-hover/qrcode:blur-[2px]': children !== undefined,
|
||||
})}
|
||||
src={imageDataUrl}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export const Cheatsheet = ({ open, onOpenChange }: CheatsheetProps) => {
|
|||
isClosing ? 'animate-slide-down' : 'animate-slide-up'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0 p-6">
|
||||
<div className="flex-shrink-0 p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<PageTitle title={t('cheatsheet.title')} />
|
||||
<Button onClick={handleClose} variant="ghost" size="icon" title={t('global.close')}>
|
||||
|
|
@ -104,7 +104,7 @@ export const Cheatsheet = ({ open, onOpenChange }: CheatsheetProps) => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-10">
|
||||
<div className="flex-1 overflow-y-auto px-4 pb-8 sm:px-6 sm:pb-10">
|
||||
<div className="flex flex-col gap-4 sm:gap-6">
|
||||
<ListItem
|
||||
number={1}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const PageTitle = ({ title, subtitle, variant = 'default', center = false }: Pag
|
|||
'text-destructive': variant === 'error',
|
||||
})}
|
||||
>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<h1 className="flex flex-wrap items-center gap-2 text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle && <p className="text-muted-foreground mb-2 text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,13 +10,16 @@ type SeedPhraseGridProps = ComponentProps<typeof MaskedText> & {
|
|||
|
||||
export const SeedPhraseGrid = ({ value, className, masked, maskedText }: SeedPhraseGridProps) => {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-2 gap-2 font-mono', className, 'select-none')}>
|
||||
<div className={cn('grid grid-cols-2 gap-2 font-mono text-sm sm:text-base', className, 'select-none')}>
|
||||
{value.map((word, index) => (
|
||||
<div key={index} className="bg-background flex min-w-32 items-center gap-2 rounded-lg border p-2">
|
||||
<span className="text-muted-foreground/60 inline-block min-w-8 ps-1 text-right tabular-nums">
|
||||
<div
|
||||
key={index}
|
||||
className="bg-background flex min-w-0 items-center gap-1.5 overflow-hidden rounded-lg border p-2"
|
||||
>
|
||||
<span className="text-muted-foreground/60 inline-block min-w-7 shrink-0 ps-1 text-right tabular-nums">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<MaskedText masked={masked} maskedText={maskedText}>
|
||||
<MaskedText className="min-w-0 truncate" masked={masked} maskedText={maskedText}>
|
||||
{word}
|
||||
</MaskedText>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function PaginationFirst({ className, ...props }: React.ComponentProps<typeof Pa
|
|||
aria-label={t('global.table.pagination.page_selector.label_first')}
|
||||
title={t('global.table.pagination.page_selector.label_first')}
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 md:pr-2.5', className)}
|
||||
className={cn('hidden gap-1 px-2.5 sm:inline-flex md:pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsLeftIcon />
|
||||
|
|
@ -82,7 +82,7 @@ function PaginationLast({ className, ...props }: React.ComponentProps<typeof Pag
|
|||
aria-label={t('global.table.pagination.page_selector.label_last')}
|
||||
title={t('global.table.pagination.page_selector.label_last')}
|
||||
size="default"
|
||||
className={cn('gap-1 px-2.5 md:pr-2.5', className)}
|
||||
className={cn('hidden gap-1 px-2.5 sm:inline-flex md:pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden md:block">{t('global.table.pagination.page_selector.label_last')}</span>
|
||||
|
|
|
|||
115
src/components/ui/responsive.test.tsx
Normal file
115
src/components/ui/responsive.test.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ButtonGroup } from './button-group'
|
||||
import { Card, CardAction, CardContent, CardFooter, CardHeader, CardTitle } from './card'
|
||||
import { Dialog, DialogContent, DialogTitle } from './dialog'
|
||||
import { BitcoinXpubQrCode } from './jam/BitcoinQrCode'
|
||||
import { SeedPhraseGrid } from './jam/SeedPhraseGrid'
|
||||
import { PaginationFirst, PaginationLast } from './pagination'
|
||||
import { Tabs, TabsList, TabsTrigger } from './tabs'
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('responsive UI contracts', () => {
|
||||
it('allows QR codes to shrink below their generated image size', async () => {
|
||||
render(<BitcoinXpubQrCode xpub="xpub-test" width={320} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('img')).toBeInTheDocument())
|
||||
const container = screen.getByRole('img').parentElement
|
||||
|
||||
expect(container).toHaveClass('aspect-square', 'w-full')
|
||||
expect(container).toHaveStyle({ maxWidth: '320px' })
|
||||
expect(screen.getByRole('img')).toHaveClass('h-full', 'w-full')
|
||||
})
|
||||
|
||||
it('keeps dialogs inside the dynamic viewport', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Responsive dialog</DialogTitle>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('dialog')).toHaveClass('max-h-[calc(100dvh-2rem)]', 'overflow-y-auto', 'p-4', 'sm:p-6')
|
||||
})
|
||||
|
||||
it('stacks card actions and uses compact spacing on phone widths', () => {
|
||||
render(
|
||||
<Card data-testid="card">
|
||||
<CardHeader data-testid="card-header">
|
||||
<CardTitle>Wallet</CardTitle>
|
||||
<CardAction data-testid="card-action">Balance</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent data-testid="card-content">Content</CardContent>
|
||||
<CardFooter data-testid="card-footer">Actions</CardFooter>
|
||||
</Card>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('card')).toHaveClass('min-w-0', 'gap-4', 'py-4', 'sm:gap-6', 'sm:py-6')
|
||||
expect(screen.getByTestId('card-header')).toHaveClass(
|
||||
'px-4',
|
||||
'has-data-[slot=card-action]:grid-cols-1',
|
||||
'sm:has-data-[slot=card-action]:grid-cols-[minmax(0,1fr)_auto]',
|
||||
)
|
||||
expect(screen.getByTestId('card-action')).toHaveClass('row-start-3', 'sm:row-start-1')
|
||||
expect(screen.getByTestId('card-content')).toHaveClass('min-w-0', 'px-4', 'sm:px-6')
|
||||
expect(screen.getByTestId('card-footer')).toHaveClass('min-w-0', 'px-4', 'sm:px-6')
|
||||
})
|
||||
|
||||
it('makes tab lists horizontally scrollable', () => {
|
||||
render(
|
||||
<Tabs defaultValue="one">
|
||||
<TabsList data-testid="tabs-list">
|
||||
<TabsTrigger value="one">One</TabsTrigger>
|
||||
<TabsTrigger value="two">Two</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tabs-list')).toHaveClass('max-w-full', 'overflow-x-auto', 'overflow-y-hidden')
|
||||
})
|
||||
|
||||
it('lets button groups and seed entries shrink within narrow containers', () => {
|
||||
render(
|
||||
<>
|
||||
<ButtonGroup data-testid="button-group">
|
||||
<input aria-label="Amount" />
|
||||
<button type="button">Submit</button>
|
||||
</ButtonGroup>
|
||||
<SeedPhraseGrid value={['abandon', 'ability']} masked={false} />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('button-group')).toHaveClass('max-w-full', '[&>input]:min-w-0')
|
||||
expect(screen.getByLabelText('Amount')).toBeInTheDocument()
|
||||
const seedWords = ['abandon', 'ability']
|
||||
seedWords.forEach((word) => {
|
||||
const wordElement = screen.getByText(word)
|
||||
expect(wordElement).toHaveClass('min-w-0', 'truncate')
|
||||
expect(wordElement.closest('div')).toHaveClass('min-w-0', 'overflow-hidden')
|
||||
})
|
||||
})
|
||||
|
||||
it('hides first and last pagination controls on phone widths', () => {
|
||||
render(
|
||||
<>
|
||||
<PaginationFirst onClick={() => undefined} />
|
||||
<PaginationLast onClick={() => undefined} />
|
||||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('global.table.pagination.page_selector.label_first')).toHaveClass(
|
||||
'hidden',
|
||||
'sm:inline-flex',
|
||||
)
|
||||
expect(screen.getByTitle('global.table.pagination.page_selector.label_last')).toHaveClass(
|
||||
'hidden',
|
||||
'sm:inline-flex',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -30,7 +30,7 @@ function SelectTrigger({
|
|||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit max-w-full min-w-0 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimi
|
|||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit max-w-full items-center justify-start overflow-x-auto overflow-y-hidden rounded-lg p-[3px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,10 @@ export const PasswordVerificationForm = ({
|
|||
{errors.password?.message && <div className="text-destructive text-xs">{errors.password.message}</div>}
|
||||
</div>
|
||||
|
||||
<Field orientation="horizontal" className="justify-end">
|
||||
<Field
|
||||
orientation="horizontal"
|
||||
className="flex-col items-stretch justify-end sm:flex-row sm:items-center [&>*]:w-full sm:[&>*]:w-auto"
|
||||
>
|
||||
{onCancel !== undefined && (
|
||||
<Button type="button" variant="outline" onClick={() => void onCancel()}>
|
||||
{t('global.cancel')}
|
||||
|
|
|
|||
|
|
@ -94,9 +94,9 @@ export const AccountDetailsTabContent = ({ value }: AccountDetailsTabContentProp
|
|||
return (
|
||||
<AccordionItem key={index} value={String(index)}>
|
||||
<AccordionTrigger className="group/account-branch-accordion-trigger items-center px-4 no-underline!">
|
||||
<div className="flex flex-col gap-0.25">
|
||||
<div className="flex min-w-0 flex-col gap-0.25">
|
||||
<span className="group-hover/account-branch-accordion-trigger:underline">{typeTitle}</span>
|
||||
<span className="text-muted-foreground font-mono select-all">{branch.derivation}</span>
|
||||
<span className="text-muted-foreground font-mono break-all select-all">{branch.derivation}</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-4 pt-2">
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!operationsEnabled || walletInfoIsFetching}
|
||||
|
|
@ -239,7 +239,7 @@ export const UtxosContent = ({ enabled, walletFileName, addressSummary, jar }: U
|
|||
placeholder={t('jar_details.utxo_list.placeholder_search')}
|
||||
value={searchFilter}
|
||||
onChange={(event_) => setSearchFilter(event_.target.value)}
|
||||
className="max-w-xs"
|
||||
className="w-full sm:max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<JarUtxosTable
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function WalletJarsDetailsOverlay({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => onOpenChange(false)} {...dialogProps}>
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
|
||||
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle className="sr-only flex items-center gap-2">
|
||||
<PageTitle title={/* todo: i18n */ t('Wallet Jars Details')} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue