feat: plakat redesign phase 3 (windows on desktop, sheets on mobile) (#76)
- 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>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useOpenWindow } from "./WindowProvider";
|
||||
import type { WindowRequest } from "./types";
|
||||
|
||||
interface OpenWindowLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
href: string;
|
||||
request: WindowRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein normaler Link (funktioniert ohne JavaScript), der mit JavaScript stattdessen
|
||||
* ein Fenster bzw. Sheet öffnet.
|
||||
*/
|
||||
export default function OpenWindowLink({ request, onClick, ...props }: OpenWindowLinkProps) {
|
||||
const openWindow = useOpenWindow();
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
aria-haspopup={openWindow ? "dialog" : undefined}
|
||||
onClick={(e) => {
|
||||
onClick?.(e);
|
||||
if (!openWindow || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
openWindow(request, e.currentTarget);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"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 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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))" }[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} />;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useId, useRef, useState, type RefObject } from "react";
|
||||
import { AnimatePresence, motion, useDragControls } from "framer-motion";
|
||||
import { Drawer } from "vaul";
|
||||
import WindowContent, { windowAccent, windowTitle } from "./WindowContent";
|
||||
import { windowId, type WindowLabels, type WindowRequest } from "./types";
|
||||
|
||||
type OpenWindow = (req: WindowRequest, trigger?: HTMLElement | null) => void;
|
||||
|
||||
const WindowContext = createContext<OpenWindow | null>(null);
|
||||
|
||||
/** Öffnet ein Fenster (Desktop) oder Sheet (Handy). Ohne Provider passiert nichts. */
|
||||
export function useOpenWindow(): OpenWindow | null {
|
||||
return useContext(WindowContext);
|
||||
}
|
||||
|
||||
interface WinState {
|
||||
id: string;
|
||||
req: WindowRequest;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
minimized: boolean;
|
||||
/** Punkt, von dem aus das Fenster aufgeht (relativ zum Fenster) */
|
||||
origin: { x: number; y: number };
|
||||
trigger: HTMLElement | null;
|
||||
}
|
||||
|
||||
const WIN_WIDTH = 440;
|
||||
|
||||
export default function WindowProvider({ labels, children }: { labels: WindowLabels; children: React.ReactNode }) {
|
||||
const [wins, setWins] = useState<WinState[]>([]);
|
||||
const [sheet, setSheet] = useState<{ req: WindowRequest; trigger: HTMLElement | null } | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const layerRef = useRef<HTMLDivElement>(null);
|
||||
const zTop = useRef(10);
|
||||
const opened = useRef(0);
|
||||
|
||||
const open = useCallback<OpenWindow>((req, trigger = null) => {
|
||||
const desktop = window.matchMedia?.("(min-width: 768px)")?.matches ?? true;
|
||||
if (!desktop) {
|
||||
setSheet({ req, trigger });
|
||||
setSheetOpen(true);
|
||||
return;
|
||||
}
|
||||
const id = windowId(req);
|
||||
setWins((prev) => {
|
||||
const existing = prev.find((w) => w.id === id);
|
||||
if (existing) {
|
||||
return prev.map((w) => (w.id === id ? { ...w, minimized: false, z: ++zTop.current } : w));
|
||||
}
|
||||
const W = window.innerWidth;
|
||||
const H = window.innerHeight;
|
||||
const width = Math.min(WIN_WIDTH, W - 32);
|
||||
const step = opened.current++ % 5;
|
||||
const x = Math.max(16, Math.min(W - width - 16, W / 2 - width / 2 + step * 34 - 60));
|
||||
const y = Math.max(76, Math.min(H - 420, H * 0.16 + step * 30));
|
||||
const r = trigger?.getBoundingClientRect();
|
||||
const origin = r ? { x: r.left + r.width / 2 - x, y: r.top + r.height / 2 - y } : { x: width / 2, y: 0 };
|
||||
return [...prev, { id, req, x, y, z: ++zTop.current, minimized: false, origin, trigger }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback((id: string) => {
|
||||
setWins((prev) => {
|
||||
const w = prev.find((x) => x.id === id);
|
||||
// Fokus zurück dorthin, wo das Fenster herkam
|
||||
if (w?.trigger?.isConnected) requestAnimationFrame(() => w.trigger?.focus({ preventScroll: true }));
|
||||
return prev.filter((x) => x.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const minimize = useCallback((id: string) => {
|
||||
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
||||
}, []);
|
||||
|
||||
const focusWin = useCallback((id: string) => {
|
||||
setWins((prev) => {
|
||||
const top = prev.reduce((a, b) => (b.z > a.z ? b : a), prev[0]);
|
||||
// Schon vorn und sichtbar: nichts zu tun (ein abgelegtes Fenster muss trotzdem zurück)
|
||||
if (top?.id === id && !top.minimized) return prev;
|
||||
return prev.map((w) => (w.id === id ? { ...w, z: ++zTop.current, minimized: false } : w));
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Escape schließt das oberste offene Fenster
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
const visible = wins.filter((w) => !w.minimized);
|
||||
if (!visible.length) return;
|
||||
const top = visible.reduce((a, b) => (b.z > a.z ? b : a));
|
||||
close(top.id);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [wins, close]);
|
||||
|
||||
return (
|
||||
<WindowContext.Provider value={open}>
|
||||
{children}
|
||||
|
||||
{/* Desktop: Fenster schweben über der Seite, die Seite bleibt bedienbar */}
|
||||
<div ref={layerRef} className="pointer-events-none fixed inset-0 z-[60]" aria-live="off">
|
||||
<AnimatePresence>
|
||||
{wins
|
||||
.filter((w) => !w.minimized)
|
||||
.map((w) => (
|
||||
<Window
|
||||
key={w.id}
|
||||
win={w}
|
||||
labels={labels}
|
||||
layerRef={layerRef}
|
||||
onClose={() => close(w.id)}
|
||||
onMinimize={() => minimize(w.id)}
|
||||
onFocus={() => focusWin(w.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{wins.length > 0 && (
|
||||
<motion.nav
|
||||
aria-label={labels.openWindows}
|
||||
className="fixed bottom-[18px] left-1/2 z-[61] hidden max-w-[calc(100vw-32px)] gap-1.5 overflow-x-auto rounded-full bg-ink p-1.5 md:flex"
|
||||
style={{ x: "-50%" }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
>
|
||||
{wins.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => focusWin(w.id)}
|
||||
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-full px-3.5 py-2 text-[13px] font-medium transition-[background-color,transform] duration-150 ease-out hover:bg-paper/15 active:scale-[0.97] ${
|
||||
w.minimized ? "text-paper/60" : "text-paper"
|
||||
}`}
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full border border-paper/40" style={{ background: windowAccent(w.req) }} aria-hidden="true" />
|
||||
{windowTitle(w.req, labels).split(" · ")[0]}
|
||||
</button>
|
||||
))}
|
||||
</motion.nav>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Handy: ein Sheet von unten, nach unten ziehen schließt */}
|
||||
<Drawer.Root
|
||||
open={sheetOpen}
|
||||
onOpenChange={(o) => {
|
||||
setSheetOpen(o);
|
||||
if (!o && sheet?.trigger?.isConnected) requestAnimationFrame(() => sheet.trigger?.focus({ preventScroll: true }));
|
||||
}}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-[70] bg-ink/40" />
|
||||
<Drawer.Content
|
||||
className="fixed inset-x-0 bottom-0 z-[71] flex max-h-[88svh] flex-col rounded-t-[22px] border-2 border-b-0 border-ink bg-paper text-ink outline-none"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<Drawer.Handle className="!mt-2.5 !h-[5px] !w-11 !bg-ink/30" />
|
||||
{sheet && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 border-b-2 border-ink px-5 pb-3 pt-2">
|
||||
<Drawer.Title className="flex-1 truncate font-mono text-[13px] font-normal">{windowTitle(sheet.req, labels)}</Drawer.Title>
|
||||
<Drawer.Close
|
||||
className="grid h-8 w-8 place-items-center rounded-full border-2 border-ink text-[15px] font-semibold transition-transform duration-150 ease-out active:scale-95"
|
||||
aria-label={labels.close}
|
||||
>
|
||||
×
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5 pb-[calc(20px+env(safe-area-inset-bottom,0px))]">
|
||||
<WindowContent req={sheet.req} labels={labels} onClose={() => setSheetOpen(false)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</WindowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface WindowProps {
|
||||
win: WinState;
|
||||
labels: WindowLabels;
|
||||
layerRef: RefObject<HTMLDivElement | null>;
|
||||
onClose: () => void;
|
||||
onMinimize: () => void;
|
||||
onFocus: () => void;
|
||||
}
|
||||
|
||||
function Window({ win, labels, layerRef, onClose, onMinimize, onFocus }: WindowProps) {
|
||||
const controls = useDragControls();
|
||||
const [lifted, setLifted] = useState(false);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
role="dialog"
|
||||
aria-labelledby={titleId}
|
||||
drag
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
dragConstraints={layerRef}
|
||||
dragElastic={0.15}
|
||||
dragTransition={{ power: 0.3, timeConstant: 300, bounceStiffness: 260, bounceDamping: 24 }}
|
||||
onDragStart={() => setLifted(true)}
|
||||
onDragEnd={() => setLifted(false)}
|
||||
onPointerDown={onFocus}
|
||||
initial={{ opacity: 0, scale: 0.86 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96, transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] } }}
|
||||
transition={{ duration: 0.28, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={`pointer-events-auto fixed flex max-h-[calc(100vh-120px)] w-[min(440px,calc(100vw-32px))] flex-col rounded-[18px] border-2 border-ink bg-paper text-ink transition-shadow duration-200 ease-out ${
|
||||
lifted ? "shadow-[16px_18px_0_rgb(var(--plakat-ink))]" : "shadow-[8px_8px_0_rgb(var(--plakat-ink))]"
|
||||
}`}
|
||||
style={{ left: win.x, top: win.y, zIndex: win.z, transformOrigin: `${win.origin.x}px ${win.origin.y}px` }}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab touch-none select-none items-center gap-2.5 border-b-2 border-ink py-2.5 pl-4 pr-2.5 active:cursor-grabbing"
|
||||
onPointerDown={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<span id={titleId} className="flex-1 truncate font-mono text-[13px]">
|
||||
{windowTitle(win.req, labels)}
|
||||
</span>
|
||||
<HeadButton label={labels.minimize} onClick={onMinimize}>
|
||||
–
|
||||
</HeadButton>
|
||||
<HeadButton label={labels.close} onClick={onClose} buttonRef={closeRef}>
|
||||
×
|
||||
</HeadButton>
|
||||
</div>
|
||||
<div className="grid gap-4 overflow-y-auto overscroll-contain p-5">
|
||||
<WindowContent req={win.req} labels={labels} onClose={onClose} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeadButton({
|
||||
label,
|
||||
onClick,
|
||||
buttonRef,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
buttonRef?: RefObject<HTMLButtonElement | null>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="grid h-[30px] w-[30px] place-items-center rounded-full border-2 border-ink bg-paper text-[15px] font-semibold transition-[background-color,transform] duration-150 ease-out hover:bg-signal active:scale-[0.94]"
|
||||
>
|
||||
<span aria-hidden="true">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { WindowLabels } from "./types";
|
||||
|
||||
/** Alle Texte für Fenster und Sheets, serverseitig geladen und als Props weitergereicht. */
|
||||
export async function getWindowLabels(locale: string): Promise<WindowLabels> {
|
||||
const t = await getTranslations({ locale, namespace: "home.windows" });
|
||||
const tisch = await getTranslations({ locale, namespace: "home.tisch" });
|
||||
|
||||
return {
|
||||
close: t("close"),
|
||||
minimize: t("minimize"),
|
||||
openWindows: t("openWindows"),
|
||||
projectPage: t("projectPage"),
|
||||
source: t("source"),
|
||||
live: t("live"),
|
||||
stack: t("stack"),
|
||||
year: t("year"),
|
||||
now: {
|
||||
title: t("now.title"),
|
||||
days: t("now.days"),
|
||||
rows: (["study", "work", "side"] as const).map((k) => ({ label: t(`now.${k}.label`), text: t(`now.${k}.text`) })),
|
||||
},
|
||||
server: {
|
||||
title: t("server.title"),
|
||||
headline: t("server.headline"),
|
||||
rows: (["ingress", "mail", "home"] as const).map((k) => ({
|
||||
name: tisch(`servers.${k}.name`),
|
||||
detail: tisch(`servers.${k}.detail`),
|
||||
})),
|
||||
note: tisch("serverNote"),
|
||||
},
|
||||
read: {
|
||||
title: t("read.title"),
|
||||
progress: t("read.progress"),
|
||||
empty: t("read.empty"),
|
||||
library: t("read.library"),
|
||||
libraryHref: `/${locale}/books`,
|
||||
},
|
||||
about: {
|
||||
title: t("about.title"),
|
||||
paragraphs: [t("about.p1"), t("about.p2")],
|
||||
facts: (["laptop", "pc", "study", "languages"] as const).map((k) => ({
|
||||
label: t(`about.facts.${k}.label`),
|
||||
value: t(`about.facts.${k}.value`),
|
||||
})),
|
||||
},
|
||||
contact: {
|
||||
title: t("contact.title"),
|
||||
body: t("contact.body"),
|
||||
copy: t("contact.copy"),
|
||||
copied: t("contact.copied"),
|
||||
form: t("contact.form"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TischProject } from "../tisch/types";
|
||||
|
||||
// Was ein Fenster (Desktop) bzw. Sheet (Handy) zeigen kann
|
||||
export type WindowRequest =
|
||||
| { kind: "project"; project: TischProject }
|
||||
| { kind: "now" }
|
||||
| { kind: "server" }
|
||||
| { kind: "read" }
|
||||
| { kind: "about" }
|
||||
| { kind: "contact" };
|
||||
|
||||
export function windowId(req: WindowRequest): string {
|
||||
return req.kind === "project" ? `project:${req.project.id}` : req.kind;
|
||||
}
|
||||
|
||||
export interface WindowLabels {
|
||||
close: string;
|
||||
minimize: string;
|
||||
openWindows: string;
|
||||
projectPage: string;
|
||||
source: string;
|
||||
live: string;
|
||||
stack: string;
|
||||
year: string;
|
||||
now: {
|
||||
title: string;
|
||||
days: string;
|
||||
rows: { label: string; text: string }[];
|
||||
};
|
||||
server: {
|
||||
title: string;
|
||||
headline: string;
|
||||
rows: { name: string; detail: string }[];
|
||||
note: string;
|
||||
};
|
||||
read: {
|
||||
title: string;
|
||||
progress: string;
|
||||
empty: string;
|
||||
library: string;
|
||||
libraryHref: string;
|
||||
};
|
||||
about: {
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
facts: { label: string; value: string }[];
|
||||
};
|
||||
contact: {
|
||||
title: string;
|
||||
body: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
form: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user