'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(false) const [error, setError] = useState(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 (
Koharu

{t('welcome.title')}

{t('welcome.subtitle')}

{error && (
{error}
)}
setNewDialogOpen(true)} disabled={!!busy} loading={busy === 'new'} title={t('welcome.new')} description={t('welcome.newDescription')} /> } label={t('welcome.importKhr')} />

{t('welcome.projects')}

{projects.length > 0 && ( {projects.length} )}
{projects.length > 0 ? (
    {projects.map((p) => ( ))}
) : ( )}
) } // --------------------------------------------------------------------------- function PrimaryAction({ onClick, disabled, loading, title, description, }: { onClick: () => void disabled?: boolean loading?: boolean title: string description: string }) { return ( ) } function SecondaryAction({ onClick, disabled, loading, icon, label, }: { onClick: () => void disabled?: boolean loading?: boolean icon: React.ReactNode label: string }) { return ( ) } function RecentSkeleton() { const { t } = useTranslation() const widths = ['w-32', 'w-40', 'w-28'] return (
    {widths.map((w, i) => (
  • ))}

{t('welcome.emptyHint')}

) } 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 (
  • ) } 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 ( { onOpenChange(o) if (!o) setName('') }} > {t('welcome.newDialogTitle')} {t('welcome.newDialogDescription')}
    { e.preventDefault() if (canSubmit) onSubmit(trimmed) }} className='flex flex-col gap-4' > setName(e.target.value)} placeholder={t('welcome.newDialogPlaceholder')} />
    ) }