mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
133 lines
6.5 KiB
TypeScript
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>
|
|
)
|
|
}
|