Files
portfolio/app/components/tisch/ObjectFace.tsx
T
denshooter 6c2da49bfa feat: plakat redesign phase 4 (chat window, contact, footer, cleanup)
- AI chat moves into a window/sheet, opened from a new speech-bubble card on
  the desk; it now respects the chatbot consent and asks before sending
  anything to n8n (previously it ignored the consent)
- Contact as red poster section with big mail address and the form on a paper
  card; validation errors are finally shown inline (aria-invalid, role=alert)
- Footer as ink strip with dk0 wordmark, legal links and a button to reopen
  the privacy settings
- "About me" in the header opens the about window on the home page
- Remove the old About section and its parts (BentoChat, ActivityFeed,
  CurrentlyReading, ReadBooks), the unused ChatWidget, Projects and BentoGrid,
  their tests, wrappers, loaders, types and texts
- scripts/directus-cleanup-redesign.mjs: dry run by default, --apply archives
  hobbies, tech stack categories and old content pages and deletes four
  unused message overrides after a full JSON backup; run only after the
  redesign is live on production

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:43 +02:00

153 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import type { TischLabels, TischObject } from "./types";
import { loadCurrentBook, type CurrentBook } from "./currentBook";
interface ObjectFaceProps {
obj: TischObject;
labels: TischLabels;
}
const pad = (n: number) => String(n).padStart(2, "0");
// Rand und Rundung, die alle Objekte teilen
const edge = "h-full w-full overflow-hidden rounded-2xl border-2 border-ink";
/** Zeichnet ein Objekt. Größe und Position bestimmt der Container (Tisch oder Stapel). */
export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
switch (obj.kind) {
case "project":
return (
<div className={`${edge} flex flex-col justify-between p-4`} style={{ background: obj.color, color: obj.textColor }}>
<div className="flex justify-between font-mono text-xs">
<span>({pad(obj.index + 1)})</span>
<span>{obj.year}</span>
</div>
<h3 className="font-display text-[2.4rem] font-extrabold leading-[0.88] tracking-[-0.045em] [overflow-wrap:anywhere] [font-variation-settings:'wdth'_78] [color:inherit]">
{obj.title}
</h3>
<div className="font-mono text-xs leading-snug">
<div>{obj.tags.slice(0, 3).join(" · ")}</div>
<div className="mt-2 opacity-70">{labels.flip} ↗</div>
</div>
</div>
);
case "ticket":
return (
<div className={`${edge} grid grid-cols-[1fr_76px] bg-ink text-paper`}>
<div className="flex flex-col justify-between p-4">
<span className="font-mono text-[11.5px] uppercase tracking-wide">{labels.ticketTitle}</span>
<div>
<div className="font-display text-[4.4rem] font-extrabold leading-[0.8] tracking-[-0.05em] text-signal [font-variant-numeric:tabular-nums]">
{obj.daysLeft}
</div>
<div className="mt-1 text-sm font-semibold">{labels.ticketDays}</div>
</div>
<span className="font-mono text-[11.5px] leading-snug">{labels.ticketJob}</span>
</div>
<div className="grid place-items-center border-l-2 border-dashed border-paper/30 font-mono text-[11px] uppercase tracking-[0.12em] text-signal [transform:rotate(180deg)] [writing-mode:vertical-rl]">
{labels.ticketStub}
</div>
</div>
);
case "server":
return (
<div className={`${edge} flex flex-col gap-3 bg-paper p-4 text-ink`}>
<h3 className="font-display text-2xl font-extrabold leading-none tracking-[-0.03em]">{labels.serverTitle}</h3>
{labels.servers.map((s) => (
<div key={s.name} className="grid grid-cols-[10px_1fr] items-center gap-2.5 text-sm">
<span className="h-2.5 w-2.5 rounded-full bg-ok shadow-[0_0_0_3px_rgb(var(--plakat-ok)/0.2)]" aria-hidden="true" />
<span>
{s.name}
<span className="block font-mono text-[11.5px] opacity-75">{s.detail}</span>
</span>
</div>
))}
<p className="mt-auto border-t-2 border-ink/15 pt-2.5 font-mono text-[11.5px] leading-snug">{labels.serverNote}</p>
</div>
);
case "book":
return <BookFace href={obj.href} labels={labels} />;
case "photo":
return (
<div className="flex h-full w-full flex-col rounded-md border-2 border-ink bg-polaroid p-2.5 pb-0 text-polaroid-ink">
<div className="relative w-full flex-1 overflow-hidden rounded-sm bg-paper-2">
<Image src="/images/me.jpg" alt="Dennis Konkol" fill sizes="220px" className="object-cover" draggable={false} />
</div>
<div className="py-2.5 text-center font-display text-lg font-bold">{labels.photoCaption}</div>
</div>
);
case "chat":
return (
<div className="flex h-full w-full flex-col justify-between rounded-[22px_22px_22px_6px] border-2 border-ink bg-ink p-4 text-paper">
<span className="font-mono text-xs uppercase tracking-wide text-signal">KI · n8n</span>
<span className="font-display text-[1.7rem] font-extrabold leading-[0.95] tracking-[-0.03em]">{labels.chatCard}</span>
<span className="font-mono text-xs opacity-70">{labels.chatCardHint} ↗</span>
</div>
);
case "sticker":
return (
<div className="grid h-full w-full place-items-center rounded-full border-2 border-ink bg-signal p-6 text-center font-display text-xl font-extrabold leading-[0.98] tracking-[-0.02em] text-ink">
{labels.sticker}
</div>
);
}
}
function BookFace({ href, labels }: { href: string; labels: TischLabels }) {
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
useEffect(() => {
let alive = true;
loadCurrentBook().then((b) => {
if (alive) setBook(b);
});
return () => {
alive = false;
};
}, []);
const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0;
return (
<div className="relative h-full w-full overflow-hidden rounded-[6px_14px_14px_6px] border-2 border-ink bg-bookcover text-bookcover-text shadow-[inset_6px_0_0_rgba(255,255,255,0.08)]">
{book?.image && (
<Image src={book.image} alt="" fill sizes="200px" className="object-cover opacity-40" draggable={false} />
)}
<div className="relative flex h-full flex-col justify-between p-4">
<span className="font-mono text-xs uppercase tracking-wide">{labels.bookLabel}</span>
{book === undefined ? (
<div className="space-y-2" aria-hidden="true">
<div className="h-7 w-4/5 animate-pulse rounded bg-white/15" />
<div className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
</div>
) : book ? (
<div>
<div className="line-clamp-3 font-display text-[1.9rem] font-extrabold leading-[0.92] tracking-[-0.03em]">{book.title}</div>
<div className="mt-1.5 font-mono text-xs">{book.authors.join(", ")}</div>
</div>
) : (
<Link href={href} className="font-display text-2xl font-extrabold leading-tight underline decoration-2 underline-offset-4">
{labels.bookFallback}
</Link>
)}
<div>
<div className="font-mono text-xs">{book ? `${progress} %` : " "}</div>
<div className="mt-1.5 h-1 overflow-hidden rounded bg-white/20">
<div className="h-full origin-left bg-signal" style={{ transform: `scaleX(${progress / 100})` }} />
</div>
</div>
</div>
</div>
);
}