# Conflicts: # packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx # packages/editor/src/components/tools/select/box-select-tool.tsx # packages/editor/src/components/tools/wall/move-wall-tool.tsx # packages/editor/src/components/ui/panels/column-panel.tsx # packages/editor/src/components/ui/panels/door-panel.tsx # packages/editor/src/components/ui/panels/window-panel.tsx # packages/viewer/src/components/renderers/column/column-renderer.tsx # packages/viewer/src/components/viewer/index.tsx # packages/viewer/src/systems/slab/slab-system.tsx
627 lines
20 KiB
TypeScript
627 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
type DragEndEvent,
|
|
type DragStartEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import {
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
type BuildingNode,
|
|
LevelNode,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { ClipboardPaste, Copy, GripVertical, MoreVertical, Plus, Trash2 } from 'lucide-react'
|
|
import {
|
|
type ButtonHTMLAttributes,
|
|
type CSSProperties,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
useSyncExternalStore,
|
|
} from 'react'
|
|
import { useShallow } from 'zustand/react/shallow'
|
|
import {
|
|
buildLevelDuplicateCreateOps,
|
|
type LevelDuplicatePreset,
|
|
} from '../../lib/level-duplication'
|
|
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
|
|
import {
|
|
getEditorClipboardSnapshot,
|
|
pasteEditorClipboardToLevel,
|
|
subscribeEditorClipboard,
|
|
} from '../../lib/scene-clipboard'
|
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
import { cn } from '../../lib/utils'
|
|
import { LevelDuplicateDialog } from './level-duplicate-dialog'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from './primitives/dialog'
|
|
import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
|
|
|
|
function getLevelDisplayLabel(level: LevelNode) {
|
|
return level.name || `Level ${level.level}`
|
|
}
|
|
|
|
// ── Inline rename input for a level row ─────────────────────────────────────
|
|
|
|
function LevelInlineRename({
|
|
level,
|
|
isEditing,
|
|
onStopEditing,
|
|
}: {
|
|
level: LevelNode
|
|
isEditing: boolean
|
|
onStopEditing: () => void
|
|
}) {
|
|
const updateNode = useScene((s) => s.updateNode)
|
|
const defaultName = `Level ${level.level}`
|
|
const [value, setValue] = useState(level.name || '')
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (isEditing) {
|
|
setValue(level.name || '')
|
|
setTimeout(() => {
|
|
inputRef.current?.focus()
|
|
inputRef.current?.select()
|
|
}, 0)
|
|
}
|
|
}, [isEditing, level.name])
|
|
|
|
const handleSave = useCallback(() => {
|
|
const trimmed = value.trim()
|
|
if (trimmed !== level.name) {
|
|
updateNode(level.id, { name: trimmed || undefined })
|
|
}
|
|
onStopEditing()
|
|
}, [value, level.id, level.name, updateNode, onStopEditing])
|
|
|
|
if (!isEditing) return null
|
|
|
|
return (
|
|
<input
|
|
className="m-0 h-full w-full min-w-0 rounded-lg bg-transparent px-2.5 py-1.5 font-medium text-foreground text-xs outline-none ring-1 ring-primary/50"
|
|
onBlur={handleSave}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleSave()
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
onStopEditing()
|
|
}
|
|
}}
|
|
placeholder={defaultName}
|
|
ref={inputRef}
|
|
type="text"
|
|
value={value}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// ── Level row with three-dot menu ───────────────────────────────────────────
|
|
|
|
function LevelRow({
|
|
level,
|
|
isSelected,
|
|
isDragging,
|
|
dragHandleProps,
|
|
dragHandleRef,
|
|
onSelect,
|
|
onDuplicate,
|
|
onPaste,
|
|
onRequestDelete,
|
|
}: {
|
|
level: LevelNode
|
|
isSelected: boolean
|
|
isDragging?: boolean
|
|
dragHandleProps?: ButtonHTMLAttributes<HTMLButtonElement>
|
|
dragHandleRef?: (element: HTMLButtonElement | null) => void
|
|
onSelect: () => void
|
|
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
|
onPaste?: () => void
|
|
onRequestDelete: () => void
|
|
}) {
|
|
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
|
|
return (
|
|
<div className="group/level">
|
|
{isEditing ? (
|
|
<LevelInlineRename
|
|
isEditing={isEditing}
|
|
level={level}
|
|
onStopEditing={() => setIsEditing(false)}
|
|
/>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'flex items-center rounded-lg transition-colors',
|
|
isDragging && 'bg-white/10 text-foreground shadow-lg',
|
|
isSelected
|
|
? 'bg-white/10 text-foreground'
|
|
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
|
|
)}
|
|
>
|
|
<button
|
|
{...dragHandleProps}
|
|
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
|
|
className={cn(
|
|
'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
|
|
isDragging && 'cursor-grabbing opacity-100',
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
dragHandleProps?.onClick?.(e)
|
|
}}
|
|
ref={dragHandleRef}
|
|
title="Drag to reorder"
|
|
type="button"
|
|
>
|
|
<GripVertical className="h-3.5 w-3.5" />
|
|
</button>
|
|
|
|
<button
|
|
className="flex min-w-0 flex-1 items-center justify-start py-1.5 pr-2 pl-1 font-medium text-xs"
|
|
onClick={onSelect}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation()
|
|
setIsEditing(true)
|
|
}}
|
|
title={getLevelDisplayLabel(level)}
|
|
type="button"
|
|
>
|
|
<span className="truncate">{getLevelDisplayLabel(level)}</span>
|
|
</button>
|
|
|
|
{/* Vertical three-dot menu — inside the pill */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground/40 opacity-0 transition-all hover:text-foreground group-hover/level:opacity-100"
|
|
onClick={(e) => e.stopPropagation()}
|
|
type="button"
|
|
>
|
|
<MoreVertical className="h-3 w-3" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-44 p-1" side="right" sideOffset={8}>
|
|
<button
|
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onDuplicate()
|
|
}}
|
|
type="button"
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
Duplicate level
|
|
</button>
|
|
<button
|
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setDuplicateDialogOpen(true)
|
|
}}
|
|
type="button"
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
Duplicate with options...
|
|
</button>
|
|
{onPaste && (
|
|
<button
|
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-foreground"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onPaste()
|
|
}}
|
|
type="button"
|
|
>
|
|
<ClipboardPaste className="h-3 w-3" />
|
|
Paste copied selection
|
|
</button>
|
|
)}
|
|
<button
|
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onRequestDelete()
|
|
}}
|
|
type="button"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
Delete level
|
|
</button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
<LevelDuplicateDialog
|
|
level={level}
|
|
onConfirm={(preset) => {
|
|
onDuplicate(preset)
|
|
setDuplicateDialogOpen(false)
|
|
}}
|
|
onOpenChange={setDuplicateDialogOpen}
|
|
open={duplicateDialogOpen}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SortableLevelRow({
|
|
level,
|
|
isSelected,
|
|
onSelect,
|
|
onDuplicate,
|
|
onPaste,
|
|
onRequestDelete,
|
|
}: {
|
|
level: LevelNode
|
|
isSelected: boolean
|
|
onSelect: () => void
|
|
onDuplicate: (preset?: LevelDuplicatePreset) => void
|
|
onPaste?: () => void
|
|
onRequestDelete: () => void
|
|
}) {
|
|
const {
|
|
attributes,
|
|
isDragging,
|
|
listeners,
|
|
setActivatorNodeRef,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
} = useSortable({ id: level.id })
|
|
|
|
const style: CSSProperties = {
|
|
opacity: isDragging ? 0.86 : undefined,
|
|
position: 'relative',
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 30 : undefined,
|
|
}
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style}>
|
|
<LevelRow
|
|
dragHandleProps={{ ...attributes, ...listeners }}
|
|
dragHandleRef={setActivatorNodeRef}
|
|
isDragging={isDragging}
|
|
isSelected={isSelected}
|
|
level={level}
|
|
onDuplicate={onDuplicate}
|
|
onPaste={onPaste}
|
|
onRequestDelete={onRequestDelete}
|
|
onSelect={onSelect}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Main component ──────────────────────────────────────────────────────────
|
|
|
|
export function FloatingLevelSelector() {
|
|
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
|
const levelId = useViewer((s) => s.selection.levelId)
|
|
const setSelection = useViewer((s) => s.setSelection)
|
|
const createNode = useScene((s) => s.createNode)
|
|
const createNodes = useScene((s) => s.createNodes)
|
|
const updateNodes = useScene((s) => s.updateNodes)
|
|
|
|
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
|
|
const [draggingLevelId, setDraggingLevelId] = useState<string | null>(null)
|
|
const clipboardSnapshot = useSyncExternalStore(
|
|
subscribeEditorClipboard,
|
|
getEditorClipboardSnapshot,
|
|
getEditorClipboardSnapshot,
|
|
)
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 4 },
|
|
}),
|
|
useSensor(KeyboardSensor, {
|
|
coordinateGetter: sortableKeyboardCoordinates,
|
|
}),
|
|
)
|
|
|
|
const resolvedBuildingId = useScene((state) => {
|
|
if (selectedBuildingId) return selectedBuildingId
|
|
const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
|
|
| BuildingNode
|
|
| undefined
|
|
return first?.id ?? null
|
|
})
|
|
|
|
const levels = useScene(
|
|
useShallow((state) => {
|
|
if (!resolvedBuildingId) return [] as LevelNode[]
|
|
const building = state.nodes[resolvedBuildingId]
|
|
if (!building || building.type !== 'building') return [] as LevelNode[]
|
|
return (building as BuildingNode).children
|
|
.map((id) => state.nodes[id])
|
|
.filter((node): node is LevelNode => node?.type === 'level')
|
|
.sort((a, b) => a.level - b.level)
|
|
}),
|
|
)
|
|
|
|
const handleAddAbove = useCallback(() => {
|
|
if (!resolvedBuildingId) return
|
|
const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1
|
|
const newLevel = LevelNode.parse({
|
|
level: maxLevel + 1,
|
|
children: [],
|
|
parentId: resolvedBuildingId,
|
|
})
|
|
createNode(newLevel, resolvedBuildingId)
|
|
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
|
|
}, [resolvedBuildingId, levels, createNode, setSelection])
|
|
|
|
const handleAddBelow = useCallback(() => {
|
|
if (!resolvedBuildingId) return
|
|
const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1
|
|
const newLevel = LevelNode.parse({
|
|
level: minLevel - 1,
|
|
children: [],
|
|
parentId: resolvedBuildingId,
|
|
})
|
|
createNode(newLevel, resolvedBuildingId)
|
|
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
|
|
}, [resolvedBuildingId, levels, createNode, setSelection])
|
|
|
|
const handleInsertBetween = useCallback(
|
|
(lowerIndex: number) => {
|
|
if (!resolvedBuildingId) return
|
|
const lower = levels[lowerIndex]
|
|
if (!lower) return
|
|
|
|
const newLevelNumber = lower.level + 1
|
|
const toShift = levels.filter((l) => l.level >= newLevelNumber)
|
|
if (toShift.length > 0) {
|
|
updateNodes(
|
|
toShift.map((l) => ({
|
|
id: l.id as AnyNodeId,
|
|
data: { level: l.level + 1 } as Partial<AnyNode>,
|
|
})),
|
|
)
|
|
}
|
|
|
|
const newLevel = LevelNode.parse({
|
|
level: newLevelNumber,
|
|
children: [],
|
|
parentId: resolvedBuildingId,
|
|
})
|
|
createNode(newLevel, resolvedBuildingId)
|
|
setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id })
|
|
},
|
|
[resolvedBuildingId, levels, createNode, updateNodes, setSelection],
|
|
)
|
|
|
|
const handleConfirmDelete = useCallback(() => {
|
|
if (!deletingLevel) return
|
|
deleteLevelWithFallbackSelection(deletingLevel.id)
|
|
setDeletingLevel(null)
|
|
}, [deletingLevel])
|
|
|
|
const handleDuplicateLevel = useCallback(
|
|
(level: LevelNode, preset: LevelDuplicatePreset = 'everything') => {
|
|
const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
|
|
nodes: useScene.getState().nodes,
|
|
level,
|
|
levels,
|
|
preset,
|
|
})
|
|
|
|
if (shiftedLevels.length > 0) {
|
|
updateNodes(
|
|
shiftedLevels.map((shiftedLevel) => ({
|
|
id: shiftedLevel.id as AnyNodeId,
|
|
data: { level: shiftedLevel.level } as Partial<AnyNode>,
|
|
})),
|
|
)
|
|
}
|
|
createNodes(createOps)
|
|
|
|
setSelection({
|
|
buildingId: resolvedBuildingId ?? undefined,
|
|
levelId: newLevelId as LevelNode['id'],
|
|
})
|
|
},
|
|
[createNodes, levels, resolvedBuildingId, setSelection, updateNodes],
|
|
)
|
|
|
|
const handlePasteToLevel = useCallback((level: LevelNode) => {
|
|
const result = pasteEditorClipboardToLevel(level.id)
|
|
if (result?.pastedIds.length) {
|
|
sfxEmitter.emit('sfx:item-place')
|
|
}
|
|
}, [])
|
|
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
setDraggingLevelId(String(event.active.id))
|
|
}, [])
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
setDraggingLevelId(null)
|
|
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
|
|
const visualLevels = [...levels].reverse()
|
|
const oldIndex = visualLevels.findIndex((level) => level.id === active.id)
|
|
const newIndex = visualLevels.findIndex((level) => level.id === over.id)
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
const reorderedVisualLevels = arrayMove(visualLevels, oldIndex, newIndex)
|
|
const levelNumbersDescending = levels.map((level) => level.level).sort((a, b) => b - a)
|
|
|
|
const updates = reorderedVisualLevels
|
|
.map((level, index) => ({
|
|
id: level.id as AnyNodeId,
|
|
nextLevel: levelNumbersDescending[index],
|
|
data: { level: levelNumbersDescending[index] } as Partial<AnyNode>,
|
|
}))
|
|
.filter(({ id, nextLevel }) => {
|
|
const currentLevel = levels.find((level) => level.id === id)
|
|
return currentLevel?.level !== nextLevel
|
|
})
|
|
.map(({ id, data }) => ({ id, data }))
|
|
|
|
if (updates.length > 0) {
|
|
updateNodes(updates)
|
|
}
|
|
},
|
|
[levels, updateNodes],
|
|
)
|
|
|
|
const handleDragCancel = useCallback(() => {
|
|
setDraggingLevelId(null)
|
|
}, [])
|
|
|
|
if (levels.length === 0) return null
|
|
|
|
const reversedLevels = [...levels].reverse()
|
|
const sortableLevelIds = reversedLevels.map((level) => level.id)
|
|
|
|
const addButtonClass =
|
|
'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground'
|
|
|
|
return (
|
|
<>
|
|
<div className="pointer-events-auto absolute top-14 left-3 z-20">
|
|
<div className="relative">
|
|
{/* Floating + at top edge */}
|
|
{!draggingLevelId && (
|
|
<button
|
|
className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
|
|
onClick={handleAddAbove}
|
|
title="Add level above"
|
|
type="button"
|
|
>
|
|
<Plus className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Floating + at bottom edge */}
|
|
{!draggingLevelId && (
|
|
<button
|
|
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
|
|
onClick={handleAddBelow}
|
|
title="Add level below"
|
|
type="button"
|
|
>
|
|
<Plus className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Level list */}
|
|
<DndContext
|
|
collisionDetection={closestCenter}
|
|
onDragCancel={handleDragCancel}
|
|
onDragEnd={handleDragEnd}
|
|
onDragStart={handleDragStart}
|
|
sensors={sensors}
|
|
>
|
|
<SortableContext items={sortableLevelIds} strategy={verticalListSortingStrategy}>
|
|
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
|
|
{reversedLevels.map((level, i) => {
|
|
const isSelected = level.id === levelId
|
|
const sortedIndex = levels.indexOf(level)
|
|
const showGapBelow = i < reversedLevels.length - 1
|
|
|
|
return (
|
|
<div className="relative" key={level.id}>
|
|
<SortableLevelRow
|
|
isSelected={isSelected}
|
|
level={level}
|
|
onDuplicate={(preset) => handleDuplicateLevel(level, preset)}
|
|
onPaste={
|
|
clipboardSnapshot ? () => handlePasteToLevel(level) : undefined
|
|
}
|
|
onRequestDelete={() => setDeletingLevel(level)}
|
|
onSelect={() =>
|
|
setSelection(
|
|
resolvedBuildingId
|
|
? { buildingId: resolvedBuildingId, levelId: level.id }
|
|
: { levelId: level.id },
|
|
)
|
|
}
|
|
/>
|
|
|
|
{showGapBelow && !draggingLevelId && (
|
|
<button
|
|
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
|
|
onClick={() => handleInsertBetween(sortedIndex - 1)}
|
|
title="Insert level here"
|
|
type="button"
|
|
>
|
|
<Plus className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
|
|
<DialogContent showCloseButton={false}>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete level</DialogTitle>
|
|
<DialogDescription>
|
|
Are you sure you want to delete{' '}
|
|
<strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
|
|
walls, floors, and objects on this level will be permanently removed.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<button
|
|
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
|
|
onClick={() => setDeletingLevel(null)}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
|
|
onClick={handleConfirmDelete}
|
|
type="button"
|
|
>
|
|
Delete
|
|
</button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|