mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
134 lines
6.6 KiB
TypeScript
134 lines
6.6 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) => (
|
||
// Im Baukasten stehen die Bausteine im Vordergrund – „Marke & Kontakt“ startet zugeklappt
|
||
<Collapsible key={section.key} defaultOpen={index === 0 && !def.builder} 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>
|
||
)
|
||
}
|
||
|
||
export 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>
|
||
)
|
||
}
|
||
|
||
export 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>
|
||
)
|
||
}
|