Reapply "Add missing admin action files and navigation links"
This reverts commit 4d515bc400.
This commit is contained in:
1 parent
96ed768f14
commit
5b4228261a
338 files changed
+28143
-5948
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ImportFurniLoading() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-5 w-72 mt-2" />
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-6 space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-10 w-24 mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Box,
|
||||
Check,
|
||||
Compass,
|
||||
Copy,
|
||||
Eye,
|
||||
Layers,
|
||||
Loader2,
|
||||
MousePointerClick,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Ruler,
|
||||
Save,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
interface NitroEditorDialogProps {
|
||||
classname: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
function updateNested(obj: any, path: string, value: unknown): any {
|
||||
const clone = JSON.parse(JSON.stringify(obj))
|
||||
const keys = path.split('.')
|
||||
let current = clone
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) current[keys[i]] = {}
|
||||
current = current[keys[i]]
|
||||
}
|
||||
current[keys[keys.length - 1]] = value
|
||||
return clone
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
function getNested(obj: any, path: string, fallback?: unknown): any {
|
||||
const keys = path.split('.')
|
||||
let current = obj
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== 'object') return fallback
|
||||
current = current[key]
|
||||
}
|
||||
return current ?? fallback
|
||||
}
|
||||
|
||||
const ALL_DIRECTIONS = [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
const DIR_LABELS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']
|
||||
|
||||
function hexToColor(hex: string): string {
|
||||
return `#${hex.padStart(6, '0')}`
|
||||
}
|
||||
|
||||
function CopyClassname({ value }: { value: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 font-mono text-sm text-muted-foreground mt-0.5 hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{value}</span>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NitroEditorDialog({ classname, open, onOpenChange }: NitroEditorDialogProps) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
const [metadata, setMetadata] = useState<any>(null)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
const [originalMetadata, setOriginalMetadata] = useState<any>(null)
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<string>('visual')
|
||||
const [flags, setFlags] = useState({ canstandon: false, cansiton: false, canlayon: false })
|
||||
const [originalFlags, setOriginalFlags] = useState({
|
||||
canstandon: false,
|
||||
cansiton: false,
|
||||
canlayon: false,
|
||||
})
|
||||
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||
|
||||
const fetchMetadata = useCallback(async () => {
|
||||
if (!classname) return
|
||||
setLoading(true)
|
||||
setJsonError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/admin/import/furni/nitro-editor?classname=${encodeURIComponent(classname)}`,
|
||||
)
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
toast.error(err.error || 'Failed to load .nitro metadata')
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setMetadata(data.metadata)
|
||||
setOriginalMetadata(JSON.parse(JSON.stringify(data.metadata)))
|
||||
setJsonText(JSON.stringify(data.metadata, null, 2))
|
||||
setFlags(data.flags)
|
||||
setOriginalFlags({ ...data.flags })
|
||||
} catch {
|
||||
toast.error('Network error loading .nitro metadata')
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [classname, onOpenChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && classname) {
|
||||
setActiveTab('visual')
|
||||
fetchMetadata()
|
||||
}
|
||||
}, [open, classname, fetchMetadata])
|
||||
|
||||
const handleTabChange = (newTab: string) => {
|
||||
if (activeTab === 'json' && newTab === 'visual') {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
setMetadata(parsed)
|
||||
setJsonError(null)
|
||||
} catch {
|
||||
toast.error('Invalid JSON — fix it before switching to Visual tab')
|
||||
return
|
||||
}
|
||||
} else if (activeTab === 'visual' && newTab === 'json') {
|
||||
setJsonText(JSON.stringify(metadata, null, 2))
|
||||
}
|
||||
setActiveTab(newTab)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (!originalMetadata) return
|
||||
setMetadata(JSON.parse(JSON.stringify(originalMetadata)))
|
||||
setFlags({ ...originalFlags })
|
||||
setJsonText(JSON.stringify(originalMetadata, null, 2))
|
||||
toast.info('Reset to original values')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
let metadataToSave = metadata
|
||||
|
||||
if (activeTab === 'json') {
|
||||
try {
|
||||
metadataToSave = JSON.parse(jsonText)
|
||||
} catch {
|
||||
toast.error('Invalid JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/import/furni/nitro-editor', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
classname,
|
||||
metadata: metadataToSave,
|
||||
...flags,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
toast.error(err.error || 'Save failed')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Nitro metadata salvato')
|
||||
setMetadata(metadataToSave)
|
||||
setOriginalMetadata(JSON.parse(JSON.stringify(metadataToSave)))
|
||||
setOriginalFlags({ ...flags })
|
||||
setJsonText(JSON.stringify(metadataToSave, null, 2))
|
||||
} catch {
|
||||
toast.error('Network error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visual tab helpers ──
|
||||
|
||||
const dims = metadata
|
||||
? getNested(metadata, 'logic.model.dimensions', { x: 1, y: 1, z: 0 })
|
||||
: { x: 1, y: 1, z: 0 }
|
||||
const directions: number[] = metadata ? getNested(metadata, 'logic.model.directions', [0]) : [0]
|
||||
|
||||
const setDim = (axis: 'x' | 'y' | 'z', value: string) => {
|
||||
const num = axis === 'z' ? parseFloat(value) || 0 : parseInt(value, 10) || 0
|
||||
setMetadata((prev: unknown) => updateNested(prev, `logic.model.dimensions.${axis}`, num))
|
||||
}
|
||||
|
||||
const toggleDirection = (dir: number) => {
|
||||
const current: number[] = getNested(metadata, 'logic.model.directions', [0])
|
||||
const next = current.includes(dir)
|
||||
? current.filter((d: number) => d !== dir)
|
||||
: [...current, dir].sort((a: number, b: number) => a - b)
|
||||
if (next.length === 0) return
|
||||
setMetadata((prev: unknown) => updateNested(prev, 'logic.model.directions', next))
|
||||
}
|
||||
|
||||
// Get visualization with size=64 for layer/color editing
|
||||
// biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code
|
||||
const viz64 = metadata?.visualizations?.find((v: any) => v.size === 64)
|
||||
const viz64Index =
|
||||
metadata?.visualizations?.findIndex((v: { size: number }) => v.size === 64) ?? -1
|
||||
|
||||
const layerCount = viz64?.layers ? Object.keys(viz64.layers).length : 0
|
||||
const colorCount = viz64?.colors ? Object.keys(viz64.colors).length : 0
|
||||
const animCount = viz64?.animations ? Object.keys(viz64.animations).length : 0
|
||||
|
||||
const handleJsonTextChange = (value: string) => {
|
||||
setJsonText(value)
|
||||
try {
|
||||
JSON.parse(value)
|
||||
setJsonError(null)
|
||||
} catch (e) {
|
||||
setJsonError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges =
|
||||
metadata &&
|
||||
originalMetadata &&
|
||||
(JSON.stringify(metadata) !== JSON.stringify(originalMetadata) ||
|
||||
JSON.stringify(flags) !== JSON.stringify(originalFlags))
|
||||
|
||||
// Furni preview URL
|
||||
const starIdx = classname.indexOf('*')
|
||||
const baseClassname = starIdx !== -1 ? classname.substring(0, starIdx) : classname
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0">
|
||||
{/* Header with preview */}
|
||||
<DialogHeader className="px-6 pt-5 pb-4 border-b bg-muted/30">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Furni preview */}
|
||||
<div
|
||||
className="w-20 h-20 rounded-lg border bg-card flex items-center justify-center shrink-0 overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: 'url(/images/furni_floor.png)',
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/swf/dcr/hof_furni/icons/${baseClassname}_icon.png`}
|
||||
alt={classname}
|
||||
className="max-w-[60px] max-h-[60px] drop-shadow-md"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.opacity = '0'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-lg">Edit Nitro</DialogTitle>
|
||||
<CopyClassname value={classname} />
|
||||
{/* Quick stats */}
|
||||
{metadata && (
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Ruler className="w-2.5 h-2.5" />
|
||||
{dims.x}×{dims.y}×{dims.z}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Compass className="w-2.5 h-2.5" />
|
||||
{directions.length} dir
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Layers className="w-2.5 h-2.5" />
|
||||
{layerCount} layers
|
||||
</Badge>
|
||||
{colorCount > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Palette className="w-2.5 h-2.5" />
|
||||
{colorCount} colori
|
||||
</Badge>
|
||||
)}
|
||||
{animCount > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Sparkles className="w-2.5 h-2.5" />
|
||||
{animCount} anim
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-[10px] gap-1">
|
||||
<Eye className="w-2.5 h-2.5" />
|
||||
{metadata.visualizationType || '?'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : metadata ? (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div className="px-6 pt-3 pb-0">
|
||||
<TabsList className="w-fit">
|
||||
<TabsTrigger value="visual" className="gap-1.5">
|
||||
<Box className="w-3.5 h-3.5" />
|
||||
Visual
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-1.5">
|
||||
{'{ }'}
|
||||
JSON
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ── Visual Tab ── */}
|
||||
<TabsContent value="visual" className="overflow-y-auto flex-1 px-6 py-4 space-y-5">
|
||||
{/* Dimensions + Interaction in 2-column layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Dimensions card */}
|
||||
<section className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Ruler className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Dimensioni</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="dim-x" className="text-xs text-muted-foreground">
|
||||
X (larghezza)
|
||||
</Label>
|
||||
<Input
|
||||
id="dim-x"
|
||||
type="number"
|
||||
min={1}
|
||||
value={dims.x}
|
||||
onChange={(e) => setDim('x', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dim-y" className="text-xs text-muted-foreground">
|
||||
Y (profondità)
|
||||
</Label>
|
||||
<Input
|
||||
id="dim-y"
|
||||
type="number"
|
||||
min={1}
|
||||
value={dims.y}
|
||||
onChange={(e) => setDim('y', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dim-z" className="text-xs text-muted-foreground">
|
||||
Z (altezza)
|
||||
</Label>
|
||||
<Input
|
||||
id="dim-z"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0}
|
||||
value={dims.z}
|
||||
onChange={(e) => setDim('z', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interaction card */}
|
||||
<section className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MousePointerClick className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Interazione</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
|
||||
<span className="text-sm">Can Stand On</span>
|
||||
<Switch
|
||||
checked={flags.canstandon}
|
||||
onCheckedChange={(v) => setFlags((f) => ({ ...f, canstandon: !!v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
|
||||
<span className="text-sm">Can Sit On</span>
|
||||
<Switch
|
||||
checked={flags.cansiton}
|
||||
onCheckedChange={(v) => setFlags((f) => ({ ...f, cansiton: !!v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 transition-colors">
|
||||
<span className="text-sm">Can Lay On</span>
|
||||
<Switch
|
||||
checked={flags.canlayon}
|
||||
onCheckedChange={(v) => setFlags((f) => ({ ...f, canlayon: !!v }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Directions - visual compass */}
|
||||
<section className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Compass className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Direzioni</h3>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{directions.length} attive
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-48 h-48">
|
||||
{/* Compass circle */}
|
||||
<div className="absolute inset-0 rounded-full border-2 border-muted" />
|
||||
<div className="absolute inset-4 rounded-full border border-muted/50" />
|
||||
{/* Direction buttons arranged in circle */}
|
||||
{ALL_DIRECTIONS.map((dir) => {
|
||||
const angle = (dir * 45 - 90) * (Math.PI / 180)
|
||||
const r = 80
|
||||
const x = 96 + r * Math.cos(angle) - 18
|
||||
const y = 96 + r * Math.sin(angle) - 18
|
||||
const isActive = directions.includes(dir)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={dir}
|
||||
className={`absolute w-9 h-9 rounded-full border-2 flex flex-col items-center justify-center text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground border-primary shadow-md scale-110'
|
||||
: 'bg-card text-muted-foreground border-muted hover:border-foreground/30 hover:scale-105'
|
||||
}`}
|
||||
style={{ left: x, top: y }}
|
||||
onClick={() => toggleDirection(dir)}
|
||||
>
|
||||
<span className="text-[9px] leading-none">{DIR_LABELS[dir]}</span>
|
||||
<span className="text-[8px] leading-none opacity-60">{dir}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* Center label */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{directions.length}/8
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Types */}
|
||||
<section className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Tipo</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="viz-type" className="text-xs text-muted-foreground">
|
||||
Visualization Type
|
||||
</Label>
|
||||
<Input
|
||||
id="viz-type"
|
||||
value={metadata.visualizationType || ''}
|
||||
onChange={(e) =>
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(prev, 'visualizationType', e.target.value),
|
||||
)
|
||||
}
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="logic-type" className="text-xs text-muted-foreground">
|
||||
Logic Type
|
||||
</Label>
|
||||
<Input
|
||||
id="logic-type"
|
||||
value={metadata.logicType || ''}
|
||||
onChange={(e) =>
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(prev, 'logicType', e.target.value),
|
||||
)
|
||||
}
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Layers */}
|
||||
{viz64?.layers && layerCount > 0 && (
|
||||
<section className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Layers className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Layer</h3>
|
||||
<Badge variant="secondary" className="text-[10px] ml-auto">
|
||||
{layerCount}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-4 gap-2 items-center text-[10px] font-medium text-muted-foreground uppercase tracking-wider bg-muted/50 px-3 py-1.5">
|
||||
<span>Layer</span>
|
||||
<span>Z-Index</span>
|
||||
<span>Alpha</span>
|
||||
<span>Ink</span>
|
||||
</div>
|
||||
{Object.entries(viz64.layers).map(([layerId, layerData], idx) => {
|
||||
const layer = layerData as Record<string, unknown>
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className={`grid grid-cols-4 gap-2 items-center text-sm px-3 py-1.5 ${
|
||||
idx % 2 === 0 ? 'bg-card' : 'bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5">
|
||||
#{layerId}
|
||||
</Badge>
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 text-xs"
|
||||
value={(layer.z as number) ?? 0}
|
||||
onChange={(e) => {
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(
|
||||
prev,
|
||||
`visualizations.${viz64Index}.layers.${layerId}.z`,
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 text-xs"
|
||||
min={0}
|
||||
max={255}
|
||||
value={(layer.alpha as number) ?? 255}
|
||||
onChange={(e) => {
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(
|
||||
prev,
|
||||
`visualizations.${viz64Index}.layers.${layerId}.alpha`,
|
||||
parseInt(e.target.value, 10) || 0,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
value={(layer.ink as string) ?? ''}
|
||||
placeholder="—"
|
||||
onChange={(e) => {
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(
|
||||
prev,
|
||||
`visualizations.${viz64Index}.layers.${layerId}.ink`,
|
||||
e.target.value || undefined,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Colors */}
|
||||
{viz64?.colors && colorCount > 0 && (
|
||||
<section className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Palette className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Colori</h3>
|
||||
<Badge variant="secondary" className="text-[10px] ml-auto">
|
||||
{colorCount} varianti
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(viz64.colors).map(([colorId, colorLayers]) => (
|
||||
<div key={colorId} className="rounded-md border p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs font-medium">Variante #{colorId}</span>
|
||||
{/* Color preview dots */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{Object.values(colorLayers as Record<string, { color: number }>).map(
|
||||
(lc, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: color preview dots, position is the identity
|
||||
key={i}
|
||||
className="w-4 h-4 rounded-full border border-border shadow-sm"
|
||||
style={{
|
||||
backgroundColor: hexToColor(
|
||||
typeof lc.color === 'number'
|
||||
? lc.color.toString(16).padStart(6, '0')
|
||||
: '000000',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{Object.entries(colorLayers as Record<string, { color: number }>).map(
|
||||
([layerId, layerColor]) => (
|
||||
<div key={layerId} className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground shrink-0 w-6">
|
||||
L{layerId}
|
||||
</Label>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
className="h-7 text-xs font-mono pl-8"
|
||||
value={
|
||||
typeof layerColor.color === 'number'
|
||||
? layerColor.color
|
||||
.toString(16)
|
||||
.padStart(6, '0')
|
||||
.toUpperCase()
|
||||
: String(layerColor.color)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const hex = e.target.value
|
||||
.replace(/[^0-9a-fA-F]/g, '')
|
||||
.slice(0, 6)
|
||||
const num = parseInt(hex, 16) || 0
|
||||
setMetadata((prev: unknown) =>
|
||||
updateNested(
|
||||
prev,
|
||||
`visualizations.${viz64Index}.colors.${colorId}.${layerId}.color`,
|
||||
num,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/* Color swatch inside input */}
|
||||
<div
|
||||
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-4 h-4 rounded border border-border"
|
||||
style={{
|
||||
backgroundColor: hexToColor(
|
||||
typeof layerColor.color === 'number'
|
||||
? layerColor.color.toString(16).padStart(6, '0')
|
||||
: '000000',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── JSON Tab ── */}
|
||||
<TabsContent value="json" className="flex-1 flex flex-col min-h-0 px-6 py-4">
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<Textarea
|
||||
className="h-full min-h-[400px] font-mono text-xs resize-none"
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonTextChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{jsonError && (
|
||||
<div className="absolute bottom-2 left-2 right-2 rounded-md bg-destructive/10 border border-destructive/20 px-3 py-1.5 text-xs text-destructive">
|
||||
{jsonError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="px-6 py-4 border-t bg-muted/30">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] gap-1 animate-[pulse_1s_ease-in-out_1]"
|
||||
>
|
||||
Modifiche non salvate
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={saving || loading || !metadata || !hasChanges}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
Annulla
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading || !metadata}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Salva
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { ImportFurniClient } from './import-furni-client'
|
||||
|
||||
export default async function ImportFurniPage(_props: { params: Promise<{ locale: string }> }) {
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
return <ImportFurniClient />
|
||||
}
|
||||
Reference in new issue
Block a user