Files
portfolio/app/components/fenster/WindowContent.tsx
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

254 lines
9.3 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { loadCurrentBook, type CurrentBook } from "../tisch/currentBook";
import ChatBody from "./ChatBody";
import type { WindowLabels, WindowRequest } from "./types";
const EMAIL = "contact@dk0.dev";
const btn =
"inline-flex items-center gap-2 rounded-full bg-ink px-4 py-2.5 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]";
const ghost =
"inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-sm font-semibold shadow-[inset_0_0_0_1.5px_rgb(var(--plakat-ink))] transition-[background-color,color,transform] duration-150 ease-out hover:bg-ink hover:text-paper active:scale-[0.97]";
/** Titelzeile eines Fensters, auch für die Ablage-Leiste. */
export function windowTitle(req: WindowRequest, labels: WindowLabels): string {
switch (req.kind) {
case "project":
return req.project.year ? `${req.project.title} · ${req.project.year}` : req.project.title;
case "now":
return labels.now.title;
case "server":
return labels.server.title;
case "read":
return labels.read.title;
case "about":
return labels.about.title;
case "contact":
return labels.contact.title;
case "chat":
return labels.chat.title;
}
}
/** Farbe des Punkts in der Ablage-Leiste */
export function windowAccent(req: WindowRequest): string {
if (req.kind === "project") return req.project.color;
return { now: "rgb(var(--plakat-red))", server: "rgb(var(--plakat-ok))", read: "#7a5240", about: "#2f5bea", contact: "rgb(var(--plakat-red))", chat: "#1d1b3d" }[req.kind];
}
interface WindowContentProps {
req: WindowRequest;
labels: WindowLabels;
/** Schließt das Fenster, z. B. wenn ein Link auf der Seite weiterführt */
onClose: () => void;
}
export default function WindowContent({ req, labels, onClose }: WindowContentProps) {
switch (req.kind) {
case "project": {
const p = req.project;
return (
<>
<div
className="-mx-5 -mt-5 flex min-h-[150px] flex-col justify-between border-b-2 border-ink p-5"
style={{ background: p.color, color: p.textColor }}
>
<span className="font-mono text-[12.5px]">{p.tags.slice(0, 4).join(" · ")}</span>
<span className="font-display text-[clamp(2.4rem,6vw,3.4rem)] font-extrabold leading-[0.86] tracking-[-0.045em] [font-variation-settings:'wdth'_78]">
{p.title}
</span>
</div>
<p className="text-[15px] leading-relaxed">{p.description}</p>
<dl className="border-t-2 border-ink">
{p.year && <Row label={labels.year}>{p.year}</Row>}
{p.tags.length > 0 && <Row label={labels.stack}>{p.tags.join(", ")}</Row>}
</dl>
<div className="flex flex-wrap gap-2">
<Link href={p.href} className={btn}>
{labels.projectPage} <span aria-hidden="true">→</span>
</Link>
{p.liveUrl && (
<a href={p.liveUrl} target="_blank" rel="noopener noreferrer" className={ghost}>
{labels.live} <span aria-hidden="true">↗</span>
</a>
)}
{p.githubUrl && (
<a href={p.githubUrl} target="_blank" rel="noopener noreferrer" className={ghost}>
{labels.source} <span aria-hidden="true">↗</span>
</a>
)}
</div>
</>
);
}
case "now":
return <NowBody labels={labels} />;
case "server":
return (
<>
<p className="font-display text-[1.8rem] font-extrabold leading-none tracking-[-0.03em]">{labels.server.headline}</p>
<div>
{labels.server.rows.map((r) => (
<div key={r.name} className="grid grid-cols-[10px_1fr] items-center gap-3 border-b border-ink/20 py-2.5 text-[15px]">
<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>
{r.name}
<span className="block font-mono text-xs opacity-75">{r.detail}</span>
</span>
</div>
))}
</div>
<p className="font-mono text-[12.5px] leading-snug">{labels.server.note}</p>
</>
);
case "read":
return <ReadBody labels={labels} />;
case "about":
return (
<>
{labels.about.paragraphs.map((text) => (
<p key={text} className="text-[15px] leading-relaxed">
{text}
</p>
))}
<dl className="border-t-2 border-ink">
{labels.about.facts.map((f) => (
<Row key={f.label} label={f.label}>
{f.value}
</Row>
))}
</dl>
</>
);
case "contact":
return <ContactBody labels={labels} onClose={onClose} />;
case "chat":
return <ChatBody labels={labels} />;
}
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[110px_minmax(0,1fr)] gap-3 border-b border-ink/20 py-2.5">
<dt className="font-mono text-[12.5px] leading-6">{label}</dt>
<dd className="m-0 text-[15px]">{children}</dd>
</div>
);
}
// Abgabe der Masterarbeit; auf dem Client gerechnet, damit das Fenster nie einen veralteten Stand zeigt
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
function NowBody({ labels }: { labels: WindowLabels }) {
const days = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
return (
<>
<div>
<div className="font-display text-[5rem] font-extrabold leading-[0.82] tracking-[-0.05em] text-signal [font-variant-numeric:tabular-nums]">
{days}
</div>
<p className="mt-1.5 font-semibold">{labels.now.days}</p>
</div>
<dl className="border-t-2 border-ink">
{labels.now.rows.map((r) => (
<Row key={r.label} label={r.label}>
{r.text}
</Row>
))}
</dl>
</>
);
}
function ReadBody({ labels }: { labels: WindowLabels }) {
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
useEffect(() => {
let alive = true;
loadCurrentBook().then((b) => alive && setBook(b));
return () => {
alive = false;
};
}, []);
const progress = book && Number.isFinite(book.progress) ? Math.max(0, Math.min(100, book.progress)) : 0;
return (
<>
{book === undefined ? (
<div className="grid grid-cols-[72px_1fr] gap-4" aria-hidden="true">
<div className="h-[104px] animate-pulse rounded bg-paper-2" />
<div className="space-y-2 pt-1">
<div className="h-6 w-4/5 animate-pulse rounded bg-paper-2" />
<div className="h-4 w-1/2 animate-pulse rounded bg-paper-2" />
</div>
</div>
) : book ? (
<div className="grid grid-cols-[72px_1fr] items-center gap-4">
<div className="relative h-[104px] overflow-hidden rounded-[3px_6px_6px_3px] border-2 border-ink bg-bookcover">
{book.image && <Image src={book.image} alt="" fill sizes="72px" className="object-cover" />}
</div>
<div>
<div className="font-display text-xl font-bold leading-tight">{book.title}</div>
<div className="font-mono text-xs">{book.authors.join(", ")}</div>
<div className="mt-2.5 font-mono text-xs">
{progress} % {labels.read.progress}
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded bg-paper-2">
<div className="h-full origin-left bg-ink" style={{ transform: `scaleX(${progress / 100})` }} />
</div>
</div>
</div>
) : (
<p>{labels.read.empty}</p>
)}
<Link href={labels.read.libraryHref} className={`${ghost} w-fit`}>
{labels.read.library} <span aria-hidden="true">→</span>
</Link>
</>
);
}
function ContactBody({ labels, onClose }: { labels: WindowLabels; onClose: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(EMAIL);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
window.location.href = `mailto:${EMAIL}`;
}
};
return (
<>
<div className="font-display text-[clamp(1.8rem,5vw,2.4rem)] font-extrabold leading-[0.95] tracking-[-0.04em] [overflow-wrap:anywhere]">
contact<span className="text-signal">@</span>dk0.dev
</div>
<p className="text-[15px] leading-relaxed">{labels.contact.body}</p>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={copy} className={btn} aria-live="polite">
{copied ? labels.contact.copied : labels.contact.copy}
</button>
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className={ghost}>
GitHub
</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className={ghost}>
LinkedIn
</a>
<a href="#contact" className={ghost} onClick={onClose}>
{labels.contact.form}
</a>
</div>
</>
);
}