Migrate secondary modals to shared frame
This commit is contained in:
parent
bd3b99b3ee
commit
5e0303d065
5 changed files with 228 additions and 406 deletions
|
|
@ -1,15 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogPanel,
|
|
||||||
DialogTitle,
|
|
||||||
Transition,
|
|
||||||
TransitionChild,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import { updateAppConfig } from '@/lib/client/dexie';
|
import { updateAppConfig } from '@/lib/client/dexie';
|
||||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface PrivacyModalProps {
|
interface PrivacyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -77,81 +70,46 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<ModalFrame open={isOpen} onClose={onDismiss ?? (() => {})} panelTestId="privacy-modal" className="z-[80]">
|
||||||
<Dialog as="div" className="relative z-[80]" onClose={onDismiss ?? (() => { })}>
|
<ModalTitle>Privacy & Data Usage</ModalTitle>
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-standard duration-slow"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-standard duration-base"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
|
||||||
</TransitionChild>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<PrivacyModalBody origin={origin} />
|
||||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-standard duration-slow"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-standard duration-base"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<DialogPanel data-testid="privacy-modal" className={dialogPanelStyles({ size: 'md' })}>
|
|
||||||
<DialogTitle
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-6 text-foreground"
|
|
||||||
>
|
|
||||||
Privacy & Data Usage
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<PrivacyModalBody origin={origin} />
|
<div className="mt-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
|
||||||
<div className="mt-6 space-y-4">
|
<div className="flex h-6 items-center">
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
|
<input
|
||||||
<div className="flex h-6 items-center">
|
data-testid="privacy-agree-checkbox"
|
||||||
<input
|
id="privacy-agree"
|
||||||
data-testid="privacy-agree-checkbox"
|
type="checkbox"
|
||||||
id="privacy-agree"
|
checked={agreed}
|
||||||
type="checkbox"
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
checked={agreed}
|
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
/>
|
||||||
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
|
</div>
|
||||||
/>
|
<div className="text-sm leading-6">
|
||||||
</div>
|
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
|
||||||
<div className="text-sm leading-6">
|
I have read and agree to the
|
||||||
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
|
</label>{' '}
|
||||||
I have read and agree to the
|
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
|
||||||
</label>{' '}
|
Privacy Policy
|
||||||
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
|
</a>
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
data-testid="privacy-continue-button"
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
disabled={!agreed}
|
|
||||||
onClick={handleAccept}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
data-testid="privacy-continue-button"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
disabled={!agreed}
|
||||||
|
onClick={handleAccept}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,64 +137,28 @@ export function showPrivacyModal(): void {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<ModalFrame
|
||||||
appear
|
open={show}
|
||||||
show={show}
|
onClose={handleClose}
|
||||||
as={Fragment}
|
|
||||||
afterLeave={() => {
|
afterLeave={() => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
container.remove();
|
container.remove();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog as="div" className="relative z-50" onClose={handleClose}>
|
<ModalTitle>Privacy & Data Usage</ModalTitle>
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
<PrivacyModalBody origin={origin} />
|
||||||
enter="ease-standard duration-slow"
|
|
||||||
enterFrom="opacity-0"
|
<div className="mt-6 flex justify-end">
|
||||||
enterTo="opacity-100"
|
<Button
|
||||||
leave="ease-standard duration-base"
|
variant="primary"
|
||||||
leaveFrom="opacity-100"
|
size="lg"
|
||||||
leaveTo="opacity-0"
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
Close
|
||||||
</TransitionChild>
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
</ModalFrame>
|
||||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-standard duration-slow"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-standard duration-base"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<DialogPanel className={dialogPanelStyles({ size: 'md' })}>
|
|
||||||
<DialogTitle
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-6 text-foreground"
|
|
||||||
>
|
|
||||||
Privacy & Data Usage
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<PrivacyModalBody origin={origin} />
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogPanel,
|
|
||||||
DialogTitle,
|
|
||||||
Transition,
|
|
||||||
TransitionChild,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
export type ClaimableCounts = {
|
export type ClaimableCounts = {
|
||||||
documents: number;
|
documents: number;
|
||||||
|
|
@ -74,83 +67,48 @@ export default function ClaimDataModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<ModalFrame open={isOpen} onClose={onDismiss} panelTestId="claim-modal" className="z-[80]">
|
||||||
<Dialog as="div" className="relative z-[80]" onClose={onDismiss}>
|
<ModalTitle className="mb-4">Existing Data Found</ModalTitle>
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
<p className="text-sm text-soft mb-2">
|
||||||
enter="ease-standard duration-slow"
|
We found existing anonymous data from before auth was enabled.
|
||||||
enterFrom="opacity-0"
|
Claim it now to attach it to your account.
|
||||||
enterTo="opacity-100"
|
</p>
|
||||||
leave="ease-standard duration-base"
|
|
||||||
leaveFrom="opacity-100"
|
<div className="mb-4 rounded-lg border border-line bg-surface-sunken p-3">
|
||||||
leaveTo="opacity-0"
|
<div className="text-xs font-semibold uppercase tracking-wide text-soft">Claimable data</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-soft">
|
||||||
|
<li>{claimableCounts.documents} document(s)</li>
|
||||||
|
<li>{claimableCounts.audiobooks} audiobook(s)</li>
|
||||||
|
<li>{claimableCounts.preferences} preference set(s)</li>
|
||||||
|
<li>{claimableCounts.progress} reading progress record(s)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-faint mb-6 italic">
|
||||||
|
⚠️ First user to claim this data will own it and revoke access for anyone else.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
data-testid="claim-dismiss-button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDismiss}
|
||||||
|
disabled={isClaiming}
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
Dismiss
|
||||||
</TransitionChild>
|
</Button>
|
||||||
|
<Button
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
data-testid="claim-submit-button"
|
||||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
variant="primary"
|
||||||
<TransitionChild
|
size="sm"
|
||||||
as={Fragment}
|
onClick={handleClaim}
|
||||||
enter="ease-standard duration-slow"
|
disabled={isClaiming}
|
||||||
enterFrom="opacity-0 scale-95"
|
>
|
||||||
enterTo="opacity-100 scale-100"
|
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
||||||
leave="ease-standard duration-base"
|
</Button>
|
||||||
leaveFrom="opacity-100 scale-100"
|
</div>
|
||||||
leaveTo="opacity-0 scale-95"
|
</ModalFrame>
|
||||||
>
|
|
||||||
<DialogPanel data-testid="claim-modal" className={dialogPanelStyles({ size: 'md' })}>
|
|
||||||
<DialogTitle
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
|
||||||
>
|
|
||||||
Existing Data Found
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<p className="text-sm text-soft mb-2">
|
|
||||||
We found existing anonymous data from before auth was enabled.
|
|
||||||
Claim it now to attach it to your account.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-4 rounded-lg border border-line bg-surface-sunken p-3">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-soft">Claimable data</div>
|
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-soft">
|
|
||||||
<li>{claimableCounts.documents} document(s)</li>
|
|
||||||
<li>{claimableCounts.audiobooks} audiobook(s)</li>
|
|
||||||
<li>{claimableCounts.preferences} preference set(s)</li>
|
|
||||||
<li>{claimableCounts.progress} reading progress record(s)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-faint mb-6 italic">
|
|
||||||
⚠️ First user to claim this data will own it and revoke access for anyone else.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
data-testid="claim-dismiss-button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onDismiss}
|
|
||||||
disabled={isClaiming}
|
|
||||||
>
|
|
||||||
Dismiss
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
data-testid="claim-submit-button"
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClaim}
|
|
||||||
disabled={isClaiming}
|
|
||||||
>
|
|
||||||
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
|
||||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
||||||
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import type { BaseDocument } from '@/types/documents';
|
import type { BaseDocument } from '@/types/documents';
|
||||||
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
||||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
type DexieMigrationModalProps = {
|
type DexieMigrationModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -137,78 +136,53 @@ export function DexieMigrationModal({
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<ModalFrame
|
||||||
<Dialog as="div" className="relative z-[80]" onClose={() => (closeDisabled ? null : onComplete())}>
|
open={isOpen}
|
||||||
<TransitionChild
|
onClose={() => {
|
||||||
as={Fragment}
|
if (!closeDisabled) onComplete();
|
||||||
enter="ease-standard duration-slow"
|
}}
|
||||||
enterFrom="opacity-0"
|
panelTestId="migration-modal"
|
||||||
enterTo="opacity-100"
|
className="z-[80]"
|
||||||
leave="ease-standard duration-base"
|
>
|
||||||
leaveFrom="opacity-100"
|
<ModalTitle className="mb-4">{title}</ModalTitle>
|
||||||
leaveTo="opacity-0"
|
<div className="space-y-2">
|
||||||
>
|
<p className="text-sm text-soft mb-2">
|
||||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
||||||
</TransitionChild>
|
{displayMissingCount > 0 ? (
|
||||||
|
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
) : null}
|
||||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
||||||
<TransitionChild
|
</p>
|
||||||
as={Fragment}
|
{isUploading && (
|
||||||
enter="ease-standard duration-slow"
|
<div className="space-y-1">
|
||||||
enterFrom="opacity-0 scale-95"
|
<p className="text-xs text-soft">{status}</p>
|
||||||
enterTo="opacity-100 scale-100"
|
<div className="h-2 w-full rounded bg-surface-sunken">
|
||||||
leave="ease-standard duration-base"
|
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
||||||
leaveFrom="opacity-100 scale-100"
|
</div>
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<DialogPanel data-testid="migration-modal" className={dialogPanelStyles({ size: 'md' })}>
|
|
||||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
|
|
||||||
{title}
|
|
||||||
</DialogTitle>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-soft mb-2">
|
|
||||||
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
|
||||||
{displayMissingCount > 0 ? (
|
|
||||||
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
|
||||||
) : null}
|
|
||||||
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
|
||||||
</p>
|
|
||||||
{isUploading && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-soft">{status}</p>
|
|
||||||
<div className="h-2 w-full rounded bg-surface-sunken">
|
|
||||||
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<Button
|
|
||||||
data-testid="migration-skip-button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSkip}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
|
||||||
{isUploading ? 'Uploading…' : 'Upload'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Dialog>
|
{!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
|
||||||
</Transition>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
data-testid="migration-skip-button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{isUploading ? 'Uploading…' : 'Upload'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
|
||||||
import { BaseDocument } from '@/types/documents';
|
import { BaseDocument } from '@/types/documents';
|
||||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentSelectionModalProps {
|
interface DocumentSelectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -114,116 +113,83 @@ export function DocumentSelectionModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<ModalFrame open={isOpen} onClose={onClose} size="lg" panelClassName="flex h-[80vh] flex-col" className="z-[60]">
|
||||||
<Dialog as="div" className="relative z-[60]" onClose={onClose}>
|
<ModalTitle className="mb-4 flex flex-shrink-0 items-center justify-between">
|
||||||
<TransitionChild
|
{title}
|
||||||
as={Fragment}
|
{files.length > 0 && (
|
||||||
enter="ease-standard duration-slow"
|
<div className="flex items-center text-sm font-normal">
|
||||||
enterFrom="opacity-0"
|
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
|
||||||
enterTo="opacity-100"
|
<input
|
||||||
leave="ease-standard duration-base"
|
type="checkbox"
|
||||||
leaveFrom="opacity-100"
|
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||||
leaveTo="opacity-0"
|
checked={allSelected}
|
||||||
>
|
ref={input => {
|
||||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
if (input) input.indeterminate = isIndeterminate;
|
||||||
</TransitionChild>
|
}}
|
||||||
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
/>
|
||||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
Select All
|
||||||
<TransitionChild
|
</label>
|
||||||
as={Fragment}
|
|
||||||
enter="ease-standard duration-slow"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-standard duration-base"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<DialogPanel className={dialogPanelStyles({ size: 'lg', className: 'flex h-[80vh] flex-col' })}>
|
|
||||||
<DialogTitle
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="flex items-center text-sm font-normal">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
checked={allSelected}
|
|
||||||
ref={input => {
|
|
||||||
if (input) input.indeterminate = isIndeterminate;
|
|
||||||
}}
|
|
||||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Select All
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto border border-line rounded-lg bg-background p-2 min-h-0">
|
|
||||||
{isLoading ? (
|
|
||||||
<DocumentSelectionSkeleton />
|
|
||||||
) : errorMessage ? (
|
|
||||||
<div className="flex items-center justify-center h-full text-danger">{errorMessage}</div>
|
|
||||||
) : files.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-full text-soft">No documents found.</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{files.map((file) => {
|
|
||||||
const isSelected = selectedIds.has(file.id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
onClick={(e) => handleRowClick(e, file.id)}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none
|
|
||||||
${isSelected ? 'bg-accent-wash' : 'hover:bg-accent-wash'}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`flex-1 truncate ${
|
|
||||||
isSelected ? 'text-accent font-medium' : 'text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-soft text-xs whitespace-nowrap">{formatSize(file.size)}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
|
||||||
<Button variant="outline" size="md" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
onClick={handleConfirmClick}
|
|
||||||
disabled={isLoading || selectedCount === 0 || isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Dialog>
|
</ModalTitle>
|
||||||
</Transition>
|
|
||||||
|
<div className="flex-1 overflow-auto border border-line rounded-lg bg-background p-2 min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<DocumentSelectionSkeleton />
|
||||||
|
) : errorMessage ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-danger">{errorMessage}</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-soft">No documents found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isSelected = selectedIds.has(file.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
onClick={(e) => handleRowClick(e, file.id)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none ${
|
||||||
|
isSelected ? 'bg-accent-wash' : 'hover:bg-accent-wash'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||||
|
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex-1 truncate ${
|
||||||
|
isSelected ? 'text-accent font-medium' : 'text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-soft text-xs whitespace-nowrap">{formatSize(file.size)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
||||||
|
<Button variant="outline" size="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
onClick={handleConfirmClick}
|
||||||
|
disabled={isLoading || selectedCount === 0 || isProcessing}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ModalFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export function ModalFrame({
|
||||||
panelClassName,
|
panelClassName,
|
||||||
panelTestId,
|
panelTestId,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
|
afterLeave,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -52,9 +53,10 @@ export function ModalFrame({
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
panelTestId?: string;
|
panelTestId?: string;
|
||||||
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
||||||
|
afterLeave?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Transition appear show={open} as={Fragment}>
|
<Transition appear show={open} as={Fragment} afterLeave={afterLeave}>
|
||||||
<Dialog as="div" role={undefined} className={cn('relative z-50', className)} onClose={onClose} onKeyDown={onKeyDown}>
|
<Dialog as="div" role={undefined} className={cn('relative z-50', className)} onClose={onClose} onKeyDown={onKeyDown}>
|
||||||
<TransitionChild
|
<TransitionChild
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue