trans3 / ui /components /WelcomeScreen.tsx
Mayo
chore: add i18n keys
6670c0d unverified
Raw
History Blame Contribute Delete
12.3 kB
'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'
/**
* Project-management / welcome screen. Rendered when no project is open.
* Server manages all project paths under `{data.path}/projects/` — clients
* only pass `id`. Same UX in Tauri and headless browser deployments.
*/
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>
)
}