since3-portal/modules/templates/ui/components/editor/blocks-panel.tsx
2026-10-11 18:22:32 +02:00

297 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import Image from "next/image"
import { useState } from "react"
import { BlocksIcon, ChevronDownIcon, ChevronUpIcon, CopyIcon, EyeIcon, EyeOffIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { Block, BlockDefinition, ListItem } from "../../../lib/types"
import { FieldInput, ListEditor } from "./content-panel"
// Gleiche Grenze wie resolveContent (lib/content.ts)
const MAX_BLOCKS = 40
const newId = () => crypto.randomUUID().slice(0, 8)
// Woran man den Baustein in der Liste wiedererkennt: Überschrift, Schriftzug oder erster Text
function summary(block: Block) {
const value = [block.data.title, block.data.word, block.data.text, block.data.cta].find((entry) => typeof entry === "string" && entry.trim())
return typeof value === "string" ? value.replace(/\*/g, "").replace(/\s+/g, " ").trim() : ""
}
// Baukasten im Tab „Inhalte“: Bausteine hinzufügen, sortieren, ausblenden, duplizieren, entfernen und bearbeiten.
// Aufklappen meldet den Baustein an die Vorschau (onFocus), damit sie dorthin scrollt.
export function BlocksPanel({ library, blocks, onChange, onFocus }: {
library: BlockDefinition[]
blocks: Block[]
onChange: (blocks: Block[]) => void
onFocus: (id: string) => void
}) {
const [open, setOpen] = useState<string | null>(null)
const [insertAt, setInsertAt] = useState<number | null>(null)
const byType = new Map(library.map((block) => [block.type, block]))
const full = blocks.length >= MAX_BLOCKS
function setField(id: string, field: string, value: string | ListItem[]) {
onChange(blocks.map((block) => (block.id === id ? { ...block, data: { ...block.data, [field]: value } } : block)))
}
function move(index: number, offset: number) {
const next = [...blocks]
const [block] = next.splice(index, 1)
next.splice(index + offset, 0, block)
onChange(next)
onFocus(block.id)
}
function insert(block: Block, at: number) {
onChange([...blocks.slice(0, at), block, ...blocks.slice(at)])
setOpen(block.id)
onFocus(block.id)
}
function add(type: string) {
const def = byType.get(type)
if (def && insertAt !== null) insert({ id: newId(), type, hidden: false, data: structuredClone(def.defaults) }, insertAt)
setInsertAt(null)
}
function remove(block: Block) {
const label = byType.get(block.type)?.label ?? "Baustein"
if (window.confirm(`„${label}“ entfernen? Die Inhalte dieses Bausteins gehen verloren.`)) {
onChange(blocks.filter((entry) => entry.id !== block.id))
}
}
return (
<section className="mt-6 flex flex-col">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-medium">Bausteine</h3>
<span className="text-xs text-muted-foreground">{blocks.length} auf der Seite</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">Aufklappen zum Bearbeiten – die Vorschau springt mit. Zwischen zwei Bausteinen erscheint beim Darüberfahren ein Plus zum Einfügen.</p>
<div className="mt-2 flex flex-col">
{blocks.map((block, index) => {
const def = byType.get(block.type)
if (!def) return null
const hint = summary(block)
return (
<div key={block.id}>
<InsertHere disabled={full} onClick={() => setInsertAt(index)} />
<Collapsible
open={open === block.id}
onOpenChange={(next) => {
setOpen(next ? block.id : null)
if (next) onFocus(block.id)
}}
className={cn("rounded-lg border bg-card transition-opacity", block.hidden && "opacity-55")}
>
<div className="flex items-center gap-0.5 pr-1">
<div className="flex flex-col pl-1">
<button
type="button"
aria-label={`${def.label} nach oben`}
disabled={index === 0}
onClick={() => move(index, -1)}
className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30"
>
<ChevronUpIcon className="size-3.5" />
</button>
<button
type="button"
aria-label={`${def.label} nach unten`}
disabled={index === blocks.length - 1}
onClick={() => move(index, 1)}
className="cursor-pointer rounded p-0.5 text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-30"
>
<ChevronDownIcon className="size-3.5" />
</button>
</div>
<CollapsibleTrigger className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-2.5 pr-1 pl-1.5 text-left [&[data-panel-open]>svg]:rotate-180">
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm font-medium">
{def.label}
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">{def.category}</span>
{block.hidden && <span className="text-[11px] font-normal text-muted-foreground">ausgeblendet</span>}
</span>
{hint && <span className="mt-0.5 block truncate text-xs text-muted-foreground">{hint}</span>}
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform" />
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-sm"
aria-label={block.hidden ? `${def.label} einblenden` : `${def.label} ausblenden`}
title={block.hidden ? "Einblenden" : "Ausblenden"}
onClick={() => onChange(blocks.map((entry) => (entry.id === block.id ? { ...entry, hidden: !entry.hidden } : entry)))}
>
{block.hidden ? <EyeOffIcon /> : <EyeIcon />}
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`${def.label} duplizieren`} title="Duplizieren" disabled={full} onClick={() => insert({ ...block, id: newId(), data: structuredClone(block.data) }, index + 1)}>
<CopyIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`${def.label} entfernen`} title="Entfernen" onClick={() => remove(block)}>
<Trash2Icon />
</Button>
</div>
<CollapsibleContent>
<div className="flex flex-col gap-4 border-t px-3 py-4">
{def.fields.map((field) =>
field.type === "list" ? (
<ListEditor
key={field.key}
field={field}
items={(block.data[field.key] as ListItem[] | undefined) ?? []}
onChange={(items) => setField(block.id, field.key, items)}
/>
) : (
<FieldInput
key={field.key}
field={field}
value={(block.data[field.key] as string | undefined) ?? ""}
onChange={(value) => setField(block.id, field.key, value)}
/>
)
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
)
})}
</div>
{blocks.length === 0 && (
<p className="mt-2 rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">Noch leer – füge den ersten Baustein hinzu, zum Beispiel eine Navigation.</p>
)}
<Button variant="outline" disabled={full} onClick={() => setInsertAt(blocks.length)} className="mt-3">
<PlusIcon /> Baustein hinzufügen
</Button>
{full && <p className="mt-2 text-xs text-muted-foreground">Maximal {MAX_BLOCKS} Bausteine pro Seite.</p>}
<BlockPicker library={library} open={insertAt !== null} onClose={() => setInsertAt(null)} onPick={add} />
</section>
)
}
function InsertHere({ disabled, onClick }: { disabled: boolean; onClick: () => void }) {
return (
<div className="group/insert relative flex h-3 items-center justify-center">
<span aria-hidden className="absolute inset-x-6 top-1/2 h-px bg-border opacity-0 transition-opacity group-hover/insert:opacity-100" />
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-label="Hier einen Baustein einfügen"
className="relative z-10 grid size-5 cursor-pointer place-items-center rounded-full border bg-background text-muted-foreground opacity-0 transition-opacity group-hover/insert:opacity-100 hover:text-foreground focus-visible:opacity-100 disabled:hidden"
>
<PlusIcon className="size-3" />
</button>
</div>
)
}
// Vorschaubild je Baustein (public/pictures/blocks/<typ>.webp); fehlt es, erscheint ein Symbol
function BlockThumb({ type }: { type: string }) {
const [failed, setFailed] = useState(false)
return (
<span className="relative block aspect-[16/10] overflow-hidden border-b bg-surface">
{failed ? (
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
<BlocksIcon className="size-6" />
</span>
) : (
<Image
src={`/pictures/blocks/${type}.webp`}
alt=""
fill
sizes="(min-width: 1024px) 280px, (min-width: 640px) 45vw, 90vw"
onError={() => setFailed(true)}
className="object-cover object-top transition-transform duration-500 group-hover:scale-[1.03]"
/>
)}
</span>
)
}
function BlockPicker({ library, open, onClose, onPick }: {
library: BlockDefinition[]
open: boolean
onClose: () => void
onPick: (type: string) => void
}) {
const [category, setCategory] = useState("Alle")
const [query, setQuery] = useState("")
const categories = ["Alle", ...new Set(library.map((block) => block.category))]
const term = query.trim().toLowerCase()
const visible = library.filter(
(block) =>
(category === "Alle" || block.category === category) &&
(!term || `${block.label} ${block.category} ${block.description}`.toLowerCase().includes(term))
)
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onClose()
}}
>
<DialogContent className="flex max-h-[min(880px,calc(100dvh-2rem))] flex-col gap-4 overflow-hidden p-0 sm:max-w-5xl">
<DialogHeader className="px-5 pt-5">
<DialogTitle>Baustein hinzufügen</DialogTitle>
<DialogDescription>Jeder Baustein übernimmt Farben und Schriften der Seite und lässt sich mit allen anderen kombinieren.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 px-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-wrap gap-1">
{categories.map((option) => (
<button
key={option}
type="button"
aria-pressed={category === option}
onClick={() => setCategory(option)}
className={cn(
"cursor-pointer rounded-md px-2.5 py-1 text-xs transition-colors",
category === option ? "bg-surface font-medium text-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
{option}
</button>
))}
</div>
<label className="relative shrink-0 lg:w-60">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Suchen, z. B. Galerie" aria-label="Bausteine durchsuchen" className="pl-9" />
</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((block) => (
<button
key={block.type}
type="button"
onClick={() => onPick(block.type)}
className="group flex cursor-pointer flex-col overflow-hidden rounded-xl border bg-card text-left outline-none transition-[border-color,box-shadow] hover:border-foreground/30 hover:shadow-md focus-visible:ring-3 focus-visible:ring-ring/50"
>
<BlockThumb type={block.type} />
<span className="flex flex-col gap-1 p-3">
<span className="flex flex-wrap items-center gap-2 text-sm font-medium">
{block.label}
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">{block.category}</span>
</span>
<span className="text-xs leading-relaxed text-muted-foreground">{block.description}</span>
</span>
</button>
))}
</div>
{visible.length === 0 && <p className="rounded-xl border border-dashed p-8 text-center text-sm text-muted-foreground">Kein passender Baustein gefunden.</p>}
</div>
</DialogContent>
</Dialog>
)
}