4f5089ce38
- 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>
254 lines
9.3 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|