0fa10315b2
- Tapping an object on the desk opens a window (desktop) or a bottom sheet (mobile) instead of flipping: projects, now, servers, reading, about, contact - Desktop windows in poster style (2px ink rule, hard offset shadow), open from the tapped object, thrown by the title bar with momentum and elastic viewport edges, stack by focus, minimize into a tray, Escape closes the top window, focus moves to close and returns to the trigger - Mobile sheets via vaul 1.1.2: drag down to dismiss, overlay, focus trap - Project window shows live/source links from Directus when present - Hero "Write me" opens the contact window; still a plain #contact link without JavaScript - Shared current-book loader for desk, deck and reading window - Texts in de/en, i18n key check extended Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
6.1 KiB
TypeScript
144 lines
6.1 KiB
TypeScript
"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 "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>
|
||
);
|
||
}
|