2026-02-05 10:22:06 +09:00

193 lines
6.1 KiB
TypeScript

import {
type AnyNodeId,
type GuideNode,
GuideNode as GuideNodeSchema,
type LevelNode,
type ScanNode,
ScanNode as ScanNodeSchema,
saveAsset,
useScene,
} from '@pascal-app/core'
import { Box, Image, Pencil, Plus, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import useEditor from '@/store/use-editor'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/primitives/dialog'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/primitives/popover'
interface ReferencesDialogProps {
levelId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function ReferencesDialog({ levelId, open, onOpenChange }: ReferencesDialogProps) {
const nodes = useScene((s) => s.nodes)
const createNode = useScene((s) => s.createNode)
const deleteNode = useScene((s) => s.deleteNode)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const scanInputRef = useRef<HTMLInputElement>(null)
const guideInputRef = useRef<HTMLInputElement>(null)
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (!level) return null
// Find all scan and guide children of this level
const references = Object.values(nodes).filter(
(node): node is ScanNode | GuideNode =>
(node.type === 'scan' || node.type === 'guide') && node.parentId === levelId,
)
const handleAddScan = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const url = await saveAsset(file)
const node = ScanNodeSchema.parse({
url,
name: file.name,
parentId: levelId,
})
createNode(node, levelId as AnyNodeId)
e.target.value = ''
// Auto-select and close dialog
setSelectedReferenceId(node.id)
onOpenChange(false)
},
[levelId, createNode, setSelectedReferenceId, onOpenChange],
)
const handleAddGuide = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const url = await saveAsset(file)
const node = GuideNodeSchema.parse({
url,
name: file.name,
parentId: levelId,
})
createNode(node, levelId as AnyNodeId)
e.target.value = ''
// Auto-select and close dialog
setSelectedReferenceId(node.id)
onOpenChange(false)
},
[levelId, createNode, setSelectedReferenceId, onOpenChange],
)
const handleEdit = useCallback(
(nodeId: string) => {
setSelectedReferenceId(nodeId)
onOpenChange(false)
},
[setSelectedReferenceId, onOpenChange],
)
const handleDelete = useCallback(
(nodeId: string) => {
deleteNode(nodeId as AnyNodeId)
},
[deleteNode],
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>References — {level.name || `Level ${level.level}`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
{references.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
No references yet. Add a 3D scan or guide image.
</p>
)}
{references.map((ref) => (
<div
key={ref.id}
className="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-accent/50 group"
>
{ref.type === 'scan' ? (
<Box className="w-4 h-4 shrink-0 text-muted-foreground" />
) : (
<Image className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
<span className="flex-1 truncate">
{ref.name || (ref.type === 'scan' ? '3D Scan' : 'Guide Image')}
</span>
<button
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-accent cursor-pointer"
onClick={() => handleEdit(ref.id)}
title="Edit"
>
<Pencil className="w-3 h-3" />
</button>
<button
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center rounded hover:bg-destructive/10 text-destructive cursor-pointer"
onClick={() => handleDelete(ref.id)}
title="Delete"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
<div className="flex justify-end pt-2 border-t border-border/50">
<input
ref={scanInputRef}
type="file"
accept=".glb,.gltf"
className="hidden"
onChange={handleAddScan}
/>
<input
ref={guideInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAddGuide}
/>
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm bg-primary text-primary-foreground hover:bg-primary/90 cursor-pointer">
<Plus className="w-3.5 h-3.5" />
Add
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1">
<button
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
onClick={() => scanInputRef.current?.click()}
>
<Box className="w-4 h-4" />
3D Scan
</button>
<button
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-accent cursor-pointer"
onClick={() => guideInputRef.current?.click()}
>
<Image className="w-4 h-4" />
Guide Image
</button>
</PopoverContent>
</Popover>
</div>
</DialogContent>
</Dialog>
)
}