| 'use client' |
|
|
| import { |
| AlertCircleIcon, |
| ArrowRightIcon, |
| ClockIcon, |
| FileArchiveIcon, |
| PlusIcon, |
| XIcon, |
| } from 'lucide-react' |
| import Image from 'next/image' |
| import { useCallback, useMemo, useState } from 'react' |
| import { useTranslation } from 'react-i18next' |
|
|
| import { Button } from '@/components/ui/button' |
| import { Card } from '@/components/ui/card' |
| import { |
| Dialog, |
| DialogContent, |
| DialogDescription, |
| DialogFooter, |
| DialogHeader, |
| DialogTitle, |
| } from '@/components/ui/dialog' |
| import { Input } from '@/components/ui/input' |
| import { ScrollArea } from '@/components/ui/scroll-area' |
| import { useListProjects } from '@/lib/api/default/default' |
| import type { ProjectSummary } from '@/lib/api/schemas' |
| import { importKhrFile } from '@/lib/io/pagesIo' |
| import { createAndOpenProject, switchProject } from '@/lib/io/scene' |
| import { cn } from '@/lib/utils' |
|
|
| type Busy = false | 'new' | 'open' | 'import' |
|
|
| |
| |
| |
| |
| |
| export function WelcomeScreen() { |
| const { t } = useTranslation() |
| const { data: projectsData, refetch: refetchProjects } = useListProjects() |
| const projects = useMemo(() => { |
| const all = projectsData?.projects ?? [] |
| return [...all].sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0)) |
| }, [projectsData]) |
|
|
| const [busy, setBusy] = useState<Busy>(false) |
| const [error, setError] = useState<string | null>(null) |
| const [newDialogOpen, setNewDialogOpen] = useState(false) |
|
|
| const openById = useCallback(async (id: string) => { |
| setError(null) |
| setBusy('open') |
| try { |
| await switchProject({ id }) |
| } catch (e) { |
| setError(`Open failed: ${e instanceof Error ? e.message : String(e)}`) |
| } finally { |
| setBusy(false) |
| } |
| }, []) |
|
|
| const onCreate = useCallback(async (name: string) => { |
| setError(null) |
| setBusy('new') |
| try { |
| await createAndOpenProject({ name }) |
| } catch (e) { |
| setError(`New failed: ${e instanceof Error ? e.message : String(e)}`) |
| } finally { |
| setBusy(false) |
| setNewDialogOpen(false) |
| } |
| }, []) |
|
|
| const importKhr = useCallback(async () => { |
| setError(null) |
| setBusy('import') |
| try { |
| await importKhrFile() |
| await refetchProjects() |
| } catch (e) { |
| setError(`Import failed: ${e instanceof Error ? e.message : String(e)}`) |
| } finally { |
| setBusy(false) |
| } |
| }, [refetchProjects]) |
|
|
| return ( |
| <div className='relative flex min-h-0 flex-1 items-start justify-center overflow-hidden bg-background'> |
| <div |
| aria-hidden |
| className='pointer-events-none absolute -top-40 left-1/2 h-80 w-[720px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl' |
| /> |
| |
| <div className='relative z-10 mx-auto flex w-full max-w-md flex-col gap-8 px-6 pt-24 pb-10'> |
| <header className='flex flex-col items-center gap-2 text-center'> |
| <Image src='/icon.png' alt='Koharu' width={56} height={56} priority /> |
| <div className='mt-1 flex flex-col gap-0.5'> |
| <h1 className='text-2xl font-semibold tracking-tight text-foreground'> |
| {t('welcome.title')} |
| </h1> |
| <p className='text-xs text-muted-foreground'>{t('welcome.subtitle')}</p> |
| </div> |
| </header> |
| |
| {error && ( |
| <div className='flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive'> |
| <AlertCircleIcon className='mt-0.5 h-3.5 w-3.5 shrink-0' /> |
| <div className='flex-1'>{error}</div> |
| <button |
| type='button' |
| onClick={() => setError(null)} |
| className='cursor-pointer text-destructive/70 hover:text-destructive' |
| aria-label='dismiss' |
| > |
| <XIcon className='h-3.5 w-3.5' /> |
| </button> |
| </div> |
| )} |
| |
| <div className='mt-4 flex flex-col gap-2.5'> |
| <PrimaryAction |
| onClick={() => setNewDialogOpen(true)} |
| disabled={!!busy} |
| loading={busy === 'new'} |
| title={t('welcome.new')} |
| description={t('welcome.newDescription')} |
| /> |
| <SecondaryAction |
| onClick={importKhr} |
| disabled={!!busy} |
| loading={busy === 'import'} |
| icon={<FileArchiveIcon className='h-4 w-4' />} |
| label={t('welcome.importKhr')} |
| /> |
| </div> |
| |
| <section className='flex flex-col gap-2'> |
| <div className='flex items-baseline justify-between px-0.5'> |
| <h2 className='text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase'> |
| {t('welcome.projects')} |
| </h2> |
| {projects.length > 0 && ( |
| <span className='text-[10px] text-muted-foreground tabular-nums'> |
| {projects.length} |
| </span> |
| )} |
| </div> |
| {projects.length > 0 ? ( |
| <ScrollArea className='h-48 rounded-lg border border-border/60 bg-card/30'> |
| <ul className='flex flex-col divide-y divide-border/40'> |
| {projects.map((p) => ( |
| <ProjectRow key={p.id} project={p} onOpen={openById} disabled={busy === 'open'} /> |
| ))} |
| </ul> |
| </ScrollArea> |
| ) : ( |
| <RecentSkeleton /> |
| )} |
| </section> |
| </div> |
| |
| <NewProjectDialog |
| open={newDialogOpen} |
| onOpenChange={setNewDialogOpen} |
| onSubmit={onCreate} |
| busy={busy === 'new'} |
| /> |
| </div> |
| ) |
| } |
|
|
| |
|
|
| function PrimaryAction({ |
| onClick, |
| disabled, |
| loading, |
| title, |
| description, |
| }: { |
| onClick: () => void |
| disabled?: boolean |
| loading?: boolean |
| title: string |
| description: string |
| }) { |
| return ( |
| <button |
| type='button' |
| onClick={onClick} |
| disabled={disabled} |
| className={cn( |
| 'group relative cursor-pointer overflow-hidden rounded-xl text-left outline-none', |
| 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', |
| 'disabled:cursor-not-allowed disabled:opacity-60', |
| )} |
| > |
| <Card |
| className={cn( |
| 'relative flex-row items-center gap-3 overflow-hidden rounded-xl border-primary/30 px-4 py-3', |
| 'bg-gradient-to-br from-primary/10 via-primary/5 to-transparent', |
| loading && 'border-primary/70', |
| )} |
| > |
| <div className='flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-sm shadow-primary/30'> |
| <PlusIcon className='h-4 w-4' /> |
| </div> |
| <div className='flex min-w-0 flex-1 flex-col gap-0.5'> |
| <div className='text-base leading-tight font-semibold tracking-tight text-foreground'> |
| {title} |
| </div> |
| <div className='text-xs leading-snug text-muted-foreground'>{description}</div> |
| </div> |
| <ArrowRightIcon className='h-4 w-4 shrink-0 text-muted-foreground' /> |
| </Card> |
| </button> |
| ) |
| } |
|
|
| function SecondaryAction({ |
| onClick, |
| disabled, |
| loading, |
| icon, |
| label, |
| }: { |
| onClick: () => void |
| disabled?: boolean |
| loading?: boolean |
| icon: React.ReactNode |
| label: string |
| }) { |
| return ( |
| <button |
| type='button' |
| onClick={onClick} |
| disabled={disabled} |
| className={cn( |
| 'flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-transparent px-3 py-2.5 text-sm text-muted-foreground outline-none', |
| 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background', |
| 'disabled:cursor-not-allowed disabled:opacity-60', |
| loading && 'border-primary/40 text-foreground', |
| )} |
| > |
| <span className='text-muted-foreground'>{icon}</span> |
| <span className='font-medium'>{label}</span> |
| </button> |
| ) |
| } |
|
|
| function RecentSkeleton() { |
| const { t } = useTranslation() |
| const widths = ['w-32', 'w-40', 'w-28'] |
| return ( |
| <div className='relative h-48 overflow-hidden rounded-lg border border-dashed border-border/60 bg-card/20'> |
| <ul aria-hidden className='flex flex-col divide-y divide-border/30'> |
| {widths.map((w, i) => ( |
| <li key={i} className='flex items-center gap-3 px-3 py-2'> |
| <div className='h-9 w-9 shrink-0 rounded-md bg-muted/60' /> |
| <div className='flex min-w-0 flex-1 flex-col gap-1.5'> |
| <div className={cn('h-3 rounded bg-muted/70', w)} /> |
| <div className='h-2 w-20 rounded bg-muted/40' /> |
| </div> |
| <div className='h-2 w-10 rounded bg-muted/40' /> |
| </li> |
| ))} |
| </ul> |
| <div className='absolute inset-0 flex items-center justify-center bg-gradient-to-t from-background/95 via-background/60 to-transparent'> |
| <p className='text-center text-[11px] text-muted-foreground'>{t('welcome.emptyHint')}</p> |
| </div> |
| </div> |
| ) |
| } |
|
|
| function ProjectRow({ |
| project, |
| onOpen, |
| disabled, |
| }: { |
| project: ProjectSummary |
| onOpen: (id: string) => void |
| disabled?: boolean |
| }) { |
| const when = project.updatedAtMs && project.updatedAtMs > 0 ? new Date(project.updatedAtMs) : null |
| return ( |
| <li> |
| <button |
| type='button' |
| onClick={() => onOpen(project.id)} |
| disabled={disabled} |
| className='flex w-full cursor-pointer items-center gap-3 px-3 py-2 text-left outline-none focus-visible:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-60' |
| > |
| <div className='flex min-w-0 flex-1 flex-col'> |
| <div className='truncate text-sm font-medium text-foreground'>{project.name}</div> |
| <div className='truncate text-[11px] text-muted-foreground'>{project.id}</div> |
| </div> |
| {when && ( |
| <div className='flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground'> |
| <ClockIcon className='h-3 w-3' /> |
| {formatRelative(when)} |
| </div> |
| )} |
| </button> |
| </li> |
| ) |
| } |
|
|
| function formatRelative(d: Date): string { |
| const diff = Date.now() - d.getTime() |
| const m = 60_000 |
| const h = 3_600_000 |
| const day = 86_400_000 |
| if (diff < m) return 'just now' |
| if (diff < h) return `${Math.floor(diff / m)}m ago` |
| if (diff < day) return `${Math.floor(diff / h)}h ago` |
| if (diff < day * 30) return `${Math.floor(diff / day)}d ago` |
| return d.toLocaleDateString() |
| } |
|
|
| |
|
|
| function NewProjectDialog({ |
| open, |
| onOpenChange, |
| onSubmit, |
| busy, |
| }: { |
| open: boolean |
| onOpenChange: (open: boolean) => void |
| onSubmit: (name: string) => void |
| busy: boolean |
| }) { |
| const { t } = useTranslation() |
| const [name, setName] = useState('') |
|
|
| const trimmed = name.trim() |
| const canSubmit = trimmed.length > 0 && !busy |
|
|
| return ( |
| <Dialog |
| open={open} |
| onOpenChange={(o) => { |
| onOpenChange(o) |
| if (!o) setName('') |
| }} |
| > |
| <DialogContent className='sm:max-w-md'> |
| <DialogHeader> |
| <DialogTitle>{t('welcome.newDialogTitle')}</DialogTitle> |
| <DialogDescription>{t('welcome.newDialogDescription')}</DialogDescription> |
| </DialogHeader> |
| <form |
| onSubmit={(e) => { |
| e.preventDefault() |
| if (canSubmit) onSubmit(trimmed) |
| }} |
| className='flex flex-col gap-4' |
| > |
| <Input |
| autoFocus |
| value={name} |
| onChange={(e) => setName(e.target.value)} |
| placeholder={t('welcome.newDialogPlaceholder')} |
| /> |
| <DialogFooter> |
| <Button type='button' variant='outline' onClick={() => onOpenChange(false)}> |
| {t('common.cancel')} |
| </Button> |
| <Button type='submit' disabled={!canSubmit}> |
| <PlusIcon className='h-3.5 w-3.5' /> |
| {t('welcome.newDialogSubmit')} |
| </Button> |
| </DialogFooter> |
| </form> |
| </DialogContent> |
| </Dialog> |
| ) |
| } |
|
|