since3-portal/modules/templates/ui/components/editor/content-panel.tsx
2026-10-05 08:23:02 +02:00

133 lines
6.5 KiB
TypeScript

"use client"
import { useId } from "react"
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import type { FieldDef, ListFieldDef, ListItem, TemplateData, TemplateDefinition } from "../../../lib/types"
const MAX_ITEMS = 40
export function ContentPanel({ def, data, hidden, onChange }: {
def: TemplateDefinition
data: TemplateData
hidden: string[]
onChange: (section: string, field: string, value: string | ListItem[]) => void
}) {
return (
<div className="flex flex-col gap-2">
{def.sections.map((section, index) => (
<Collapsible key={section.key} defaultOpen={index === 0} className="rounded-lg border">
<CollapsibleTrigger className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-medium [&[data-panel-open]>svg]:rotate-180">
<span className="flex items-center gap-2">
{section.label}
{hidden.includes(section.key) && (
<span className="rounded bg-surface px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground">ausgeblendet</span>
)}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-4 border-t px-3 py-4">
{section.fields.map((field) =>
field.type === "list" ? (
<ListEditor
key={field.key}
field={field}
items={(data[section.key]?.[field.key] as ListItem[] | undefined) ?? []}
onChange={(items) => onChange(section.key, field.key, items)}
/>
) : (
<FieldInput
key={field.key}
field={field}
value={(data[section.key]?.[field.key] as string | undefined) ?? ""}
onChange={(value) => onChange(section.key, field.key, value)}
/>
)
)}
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
)
}
function FieldInput({ field, value, onChange }: { field: FieldDef; value: string; onChange: (value: string) => void }) {
const id = useId()
return (
<Field>
<FieldLabel htmlFor={id}>{field.label}</FieldLabel>
{field.type === "textarea" ? (
<Textarea id={id} value={value} rows={3} onChange={(event) => onChange(event.target.value)} />
) : (
<Input
id={id}
value={value}
type={field.type === "image" ? "url" : "text"}
placeholder={field.type === "image" ? "https://images.unsplash.com/…" : undefined}
onChange={(event) => onChange(event.target.value)}
/>
)}
{field.type === "image" && value && (
<div aria-hidden className="h-20 rounded-md border bg-surface bg-cover bg-center" style={{ backgroundImage: `url("${value}")` }} />
)}
{field.hint && <FieldDescription>{field.hint}</FieldDescription>}
</Field>
)
}
function ListEditor({ field, items, onChange }: { field: ListFieldDef; items: ListItem[]; onChange: (items: ListItem[]) => void }) {
function update(index: number, key: string, value: string) {
onChange(items.map((item, i) => (i === index ? { ...item, [key]: value } : item)))
}
function move(index: number, offset: number) {
const next = [...items]
const [item] = next.splice(index, 1)
next.splice(index + offset, 0, item)
onChange(next)
}
return (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">{field.label}</p>
{items.map((item, index) => (
<div key={index} className="rounded-md border bg-surface/50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{field.itemLabel} {index + 1}</span>
<div className="flex">
<Button variant="ghost" size="icon-sm" aria-label="Nach oben" disabled={index === 0} onClick={() => move(index, -1)}>
<ChevronUpIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Nach unten" disabled={index === items.length - 1} onClick={() => move(index, 1)}>
<ChevronDownIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`${field.itemLabel} entfernen`} onClick={() => onChange(items.filter((_, i) => i !== index))}>
<Trash2Icon />
</Button>
</div>
</div>
<div className="flex flex-col gap-3">
{field.fields.map((sub) => (
<FieldInput key={sub.key} field={sub} value={item[sub.key] ?? ""} onChange={(value) => update(index, sub.key, value)} />
))}
</div>
</div>
))}
<Button
variant="outline"
size="sm"
disabled={items.length >= MAX_ITEMS}
onClick={() => onChange([...items, Object.fromEntries(field.fields.map((sub) => [sub.key, ""]))])}
>
<PlusIcon /> {field.itemLabel} hinzufügen
</Button>
</div>
)
}