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