Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 229715235f |
@@ -137,8 +137,6 @@ const STATIC_KEYS: Record<string, Record<string, string[]>> = {
|
||||
},
|
||||
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
|
||||
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] },
|
||||
"home.tisch": { TischSection: ["title", "allProjects", "hintBoard", "hintDeck", "tidy", "mess", "flip", "details", "prev", "next", "ticketTitle", "ticketDays", "ticketJob", "ticketStub", "serverTitle", "servers.ingress.name", "servers.ingress.detail", "servers.mail.name", "servers.mail.detail", "servers.home.name", "servers.home.detail", "serverNote", "bookLabel", "bookFallback", "photoCaption", "sticker"] },
|
||||
"home.windows": { HomePageServer: ["close", "minimize", "openWindows", "projectPage", "source", "live", "stack", "year", "now.title", "now.days", "now.study.label", "now.study.text", "now.work.label", "now.work.text", "now.side.label", "now.side.text", "server.title", "server.headline", "read.title", "read.progress", "read.empty", "read.library", "about.title", "about.p1", "about.p2", "about.facts.laptop.label", "about.facts.laptop.value", "about.facts.pc.label", "about.facts.pc.value", "about.facts.study.label", "about.facts.study.value", "about.facts.languages.label", "about.facts.languages.value", "contact.title", "contact.body", "contact.copy", "contact.copied", "contact.form"] },
|
||||
"home.about": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
|
||||
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
|
||||
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
|
||||
|
||||
+19
-19
@@ -1,17 +1,16 @@
|
||||
import Header from "../components/Header.server";
|
||||
import Hero from "../components/Hero";
|
||||
import TischSection from "../components/tisch/TischSection";
|
||||
import WindowProvider from "../components/fenster/WindowProvider";
|
||||
import { getWindowLabels } from "../components/fenster/getWindowLabels";
|
||||
import ScrollFadeIn from "../components/ScrollFadeIn";
|
||||
import Script from "next/script";
|
||||
import {
|
||||
getAboutTranslations,
|
||||
getProjectsTranslations,
|
||||
getContactTranslations,
|
||||
getFooterTranslations,
|
||||
} from "@/lib/translations-loader";
|
||||
import {
|
||||
AboutClient,
|
||||
ProjectsClient,
|
||||
ContactClient,
|
||||
FooterClient,
|
||||
} from "../components/ClientWrappers";
|
||||
@@ -22,11 +21,11 @@ interface HomePageServerProps {
|
||||
|
||||
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||
// Parallel laden aller Translations (hero translations handled by Hero server component)
|
||||
const [aboutT, contactT, footerT, windowLabels] = await Promise.all([
|
||||
const [aboutT, projectsT, contactT, footerT] = await Promise.all([
|
||||
getAboutTranslations(locale),
|
||||
getProjectsTranslations(locale),
|
||||
getContactTranslations(locale),
|
||||
getFooterTranslations(locale),
|
||||
getWindowLabels(locale),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -89,23 +88,24 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||
}}
|
||||
/>
|
||||
<Header locale={locale} />
|
||||
<WindowProvider labels={windowLabels}>
|
||||
<main className="relative">
|
||||
<Hero locale={locale} />
|
||||
<TischSection locale={locale} />
|
||||
<main className="relative">
|
||||
<Hero locale={locale} />
|
||||
|
||||
<ScrollFadeIn>
|
||||
<AboutClient locale={locale} translations={aboutT} />
|
||||
</ScrollFadeIn>
|
||||
|
||||
<ScrollFadeIn>
|
||||
<ContactClient locale={locale} translations={contactT} />
|
||||
</ScrollFadeIn>
|
||||
</main>
|
||||
<ScrollFadeIn>
|
||||
<FooterClient locale={locale} translations={footerT} />
|
||||
<AboutClient locale={locale} translations={aboutT} />
|
||||
</ScrollFadeIn>
|
||||
</WindowProvider>
|
||||
|
||||
<ScrollFadeIn>
|
||||
<ProjectsClient locale={locale} translations={projectsT} />
|
||||
</ScrollFadeIn>
|
||||
|
||||
<ScrollFadeIn>
|
||||
<ContactClient locale={locale} translations={contactT} />
|
||||
</ScrollFadeIn>
|
||||
</main>
|
||||
<ScrollFadeIn>
|
||||
<FooterClient locale={locale} translations={footerT} />
|
||||
</ScrollFadeIn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import KineticWordmark from "./KineticWordmark";
|
||||
import OpenWindowLink from "./fenster/OpenWindowLink";
|
||||
|
||||
interface HeroProps {
|
||||
locale: string;
|
||||
@@ -40,13 +39,12 @@ export default async function Hero({ locale }: HeroProps) {
|
||||
>
|
||||
{t("ctaWork")} <span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
<OpenWindowLink
|
||||
<a
|
||||
href="#contact"
|
||||
request={{ kind: "contact" }}
|
||||
className="inline-flex items-center rounded-full px-5 py-3.5 text-[15px] 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-signal active:scale-[0.97]"
|
||||
>
|
||||
{t("ctaContact")}
|
||||
</OpenWindowLink>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
useTransform,
|
||||
useVelocity,
|
||||
type MotionValue,
|
||||
} from "framer-motion";
|
||||
import ObjectFace from "./ObjectFace";
|
||||
import { useOpenWindow } from "../fenster/WindowProvider";
|
||||
import { labelFor, windowFor } from "./windowFor";
|
||||
import { BOARD_SIZE, BOARD_SPOTS, type TischLabels, type TischObject } from "./types";
|
||||
|
||||
interface Handle {
|
||||
el: HTMLDivElement;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
base: MotionValue<number>;
|
||||
spotRotation: number;
|
||||
}
|
||||
|
||||
interface BoardProps {
|
||||
objects: TischObject[];
|
||||
labels: TischLabels;
|
||||
}
|
||||
|
||||
const TABLE_HEIGHT = 1080;
|
||||
const SETTLE = { type: "spring", bounce: 0, duration: 0.6 } as const;
|
||||
|
||||
/** Desktop: freier Tisch. Objekte lassen sich werfen, federn am Rand zurück und lassen sich aufräumen. */
|
||||
export default function Board({ objects, labels }: BoardProps) {
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const handles = useRef(new Map<string, Handle>());
|
||||
const [order, setOrder] = useState(() => objects.map((o) => o.id));
|
||||
|
||||
const bringToFront = useCallback((id: string) => {
|
||||
setOrder((prev) => (prev[prev.length - 1] === id ? prev : [...prev.filter((x) => x !== id), id]));
|
||||
}, []);
|
||||
|
||||
const tidy = () => {
|
||||
const table = tableRef.current;
|
||||
if (!table) return;
|
||||
const width = table.clientWidth;
|
||||
const gap = 22;
|
||||
let cx = 0;
|
||||
let cy = 28;
|
||||
let rowH = 0;
|
||||
objects.forEach((obj, i) => {
|
||||
const h = handles.current.get(obj.id);
|
||||
if (!h) return;
|
||||
const { w, h: oh } = BOARD_SIZE[obj.kind];
|
||||
if (cx + w > width && cx > 0) {
|
||||
cx = 0;
|
||||
cy += rowH + gap;
|
||||
rowH = 0;
|
||||
}
|
||||
const tx = cx - h.el.offsetLeft;
|
||||
const ty = cy - h.el.offsetTop;
|
||||
cx += w + gap;
|
||||
rowH = Math.max(rowH, oh);
|
||||
const delay = i * 0.03;
|
||||
animate(h.x, tx, { ...SETTLE, delay });
|
||||
animate(h.y, ty, { ...SETTLE, delay });
|
||||
animate(h.base, 0, { ...SETTLE, delay });
|
||||
});
|
||||
};
|
||||
|
||||
const mess = () => {
|
||||
objects.forEach((obj, i) => {
|
||||
const h = handles.current.get(obj.id);
|
||||
if (!h) return;
|
||||
const delay = i * 0.03;
|
||||
animate(h.x, 0, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||
animate(h.y, 0, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||
animate(h.base, h.spotRotation, { type: "spring", bounce: 0.2, duration: 0.6, delay });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 px-4 sm:px-6 lg:px-10">
|
||||
<p className="font-mono text-[13px]">{labels.hintBoard}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={mess}
|
||||
className="rounded-full px-5 py-3 text-[15px] 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]"
|
||||
>
|
||||
{labels.mess}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={tidy}
|
||||
className="rounded-full bg-ink px-5 py-3 text-[15px] font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
|
||||
>
|
||||
{labels.tidy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={tableRef}
|
||||
className="relative mx-4 border-t-2 border-ink bg-[radial-gradient(circle_at_1px_1px,rgb(var(--plakat-ink)/0.16)_1px,transparent_0)] [background-size:22px_22px] sm:mx-6 lg:mx-10"
|
||||
style={{ height: TABLE_HEIGHT }}
|
||||
>
|
||||
{objects.map((obj, i) => (
|
||||
<BoardItem
|
||||
key={obj.id}
|
||||
obj={obj}
|
||||
labels={labels}
|
||||
spot={BOARD_SPOTS[i % BOARD_SPOTS.length]}
|
||||
z={order.indexOf(obj.id) + 1}
|
||||
tableRef={tableRef}
|
||||
onFront={() => bringToFront(obj.id)}
|
||||
register={(h) => {
|
||||
if (h) handles.current.set(obj.id, h);
|
||||
else handles.current.delete(obj.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoardItemProps {
|
||||
obj: TischObject;
|
||||
labels: TischLabels;
|
||||
spot: { x: number; y: number; r: number };
|
||||
z: number;
|
||||
tableRef: RefObject<HTMLDivElement | null>;
|
||||
onFront: () => void;
|
||||
register: (h: Handle | null) => void;
|
||||
}
|
||||
|
||||
function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardItemProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const [lifted, setLifted] = useState(false);
|
||||
// Framer meldet nach einem Wurf trotzdem einen Tap; der soll die Karte nicht umdrehen
|
||||
const dragged = useRef(false);
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
const base = useMotionValue(spot.r);
|
||||
// Neigung folgt der Wurfgeschwindigkeit, geglättet über eine Feder
|
||||
const vx = useVelocity(x);
|
||||
const tilt = useSpring(useTransform(vx, [-1600, 0, 1600], [-10, 0, 10]), { stiffness: 300, damping: 30 });
|
||||
const rotate = useTransform(() => base.get() + (reduce ? 0 : tilt.get()));
|
||||
|
||||
const { w, h } = BOARD_SIZE[obj.kind];
|
||||
const openWindow = useOpenWindow();
|
||||
const elRef = useRef<HTMLDivElement | null>(null);
|
||||
const openIt = () => openWindow?.(windowFor(obj), elRef.current);
|
||||
const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={(el) => {
|
||||
elRef.current = el;
|
||||
register(el ? { el, x, y, base, spotRotation: spot.r } : null);
|
||||
}}
|
||||
drag
|
||||
dragConstraints={tableRef}
|
||||
dragElastic={0.18}
|
||||
dragTransition={{ power: 0.35, timeConstant: 320, bounceStiffness: 260, bounceDamping: 22 }}
|
||||
whileDrag={{ scale: 1.05 }}
|
||||
onPointerDown={() => {
|
||||
dragged.current = false;
|
||||
onFront();
|
||||
}}
|
||||
onDragStart={() => {
|
||||
dragged.current = true;
|
||||
setLifted(true);
|
||||
}}
|
||||
onDragEnd={() => setLifted(false)}
|
||||
onTap={() => !dragged.current && openIt()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
openIt();
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-label={labelFor(obj, labels)}
|
||||
className={`absolute cursor-grab touch-none select-none active:cursor-grabbing ${radius} transition-shadow duration-200 ease-out ${
|
||||
lifted ? "shadow-[14px_16px_0_rgb(var(--plakat-ink))]" : "shadow-[6px_6px_0_rgb(var(--plakat-ink))]"
|
||||
}`}
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
rotate,
|
||||
width: w,
|
||||
height: h,
|
||||
left: `min(${spot.x * 100}%, calc(100% - ${w}px))`,
|
||||
top: spot.y,
|
||||
zIndex: z,
|
||||
}}
|
||||
>
|
||||
<ObjectFace obj={obj} labels={labels} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo, type Variants } from "framer-motion";
|
||||
import ObjectFace from "./ObjectFace";
|
||||
import { useOpenWindow } from "../fenster/WindowProvider";
|
||||
import { labelFor, windowFor } from "./windowFor";
|
||||
import type { TischLabels, TischObject } from "./types";
|
||||
|
||||
interface DeckProps {
|
||||
objects: TischObject[];
|
||||
labels: TischLabels;
|
||||
}
|
||||
|
||||
const VISIBLE = 4;
|
||||
// Ab dieser Strecke oder Geschwindigkeit fliegt die Karte weg (Emil: ein kurzer Wisch reicht)
|
||||
const DISTANCE = 110;
|
||||
const VELOCITY = 500;
|
||||
const EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
type Exit = { kind: "fly"; dir: 1 | -1 } | { kind: "fade" };
|
||||
|
||||
// Bekommt den aktuellen Exit-Modus über `custom` von AnimatePresence
|
||||
const exitVariants: Variants = {
|
||||
leave: (e: Exit) =>
|
||||
e.kind === "fly"
|
||||
? { x: e.dir * 460, rotate: e.dir * 18, opacity: 0, transition: { duration: 0.3, ease: EASE_OUT } }
|
||||
: { scale: 0.85, opacity: 0, transition: { duration: 0.2, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
/** Handy: Kartenstapel. Oberste Karte zur Seite wischen, antippen zum Umdrehen. */
|
||||
export default function Deck({ objects, labels }: DeckProps) {
|
||||
const [order, setOrder] = useState(() => objects.map((_, i) => i));
|
||||
// Wie die Karte geht, die den sichtbaren Stapel verlässt: oben weggeworfen oder hinten ausgeblendet
|
||||
const [exit, setExit] = useState<Exit>({ kind: "fly", dir: 1 });
|
||||
|
||||
const next = (d: 1 | -1 = 1) => {
|
||||
setExit({ kind: "fly", dir: d });
|
||||
setOrder((o) => [...o.slice(1), o[0]]);
|
||||
};
|
||||
const prev = () => {
|
||||
setExit({ kind: "fade" });
|
||||
setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)]);
|
||||
};
|
||||
|
||||
const visible = order.slice(0, VISIBLE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 px-4 font-mono text-[13px] sm:px-6">{labels.hintDeck}</p>
|
||||
<div className="relative h-[440px] overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={exit}>
|
||||
{visible
|
||||
.map((idx, depth) => (
|
||||
<DeckCard
|
||||
key={objects[idx].id}
|
||||
obj={objects[idx]}
|
||||
labels={labels}
|
||||
depth={depth}
|
||||
enteringFromLeft={depth === 0 && exit.kind === "fade"}
|
||||
onDismiss={next}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 pb-10 pt-1 sm:px-6">
|
||||
<RoundButton label={labels.prev} onClick={prev}>
|
||||
←
|
||||
</RoundButton>
|
||||
<span className="font-mono text-[13px] [font-variant-numeric:tabular-nums]" aria-live="polite">
|
||||
{pad(order[0] + 1)} / {pad(objects.length)}
|
||||
</span>
|
||||
<RoundButton label={labels.next} onClick={() => next(1)}>
|
||||
→
|
||||
</RoundButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundButton({ label, onClick, children }: { label: string; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="grid h-[52px] w-[52px] place-items-center rounded-full border-2 border-ink bg-paper text-xl font-semibold transition-[background-color,transform] duration-150 ease-out active:scale-95 active:bg-paper-2"
|
||||
>
|
||||
<span aria-hidden="true">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeckCardProps {
|
||||
obj: TischObject;
|
||||
labels: TischLabels;
|
||||
depth: number;
|
||||
enteringFromLeft: boolean;
|
||||
onDismiss: (dir: 1 | -1) => void;
|
||||
}
|
||||
|
||||
function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardProps) {
|
||||
const dragged = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const openWindow = useOpenWindow();
|
||||
const openIt = () => openWindow?.(windowFor(obj), cardRef.current);
|
||||
const x = useMotionValue(0);
|
||||
const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
|
||||
const isTop = depth === 0;
|
||||
const restRotate = depth === 0 ? 0 : depth % 2 ? 3 : -3;
|
||||
|
||||
const handleDragEnd = (_: unknown, info: PanInfo) => {
|
||||
if (Math.abs(info.offset.x) > DISTANCE || Math.abs(info.velocity.x) > VELOCITY) {
|
||||
onDismiss(info.offset.x > 0 ? 1 : -1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-x-0 top-4 mx-auto h-[390px] w-[min(300px,82vw)]"
|
||||
style={{ zIndex: 10 - depth }}
|
||||
initial={enteringFromLeft ? { x: -420, rotate: -14, opacity: 0 } : { scale: 0.85, y: 3 * 14, opacity: 0 }}
|
||||
animate={{ x: 0, scale: 1 - Math.min(depth, 3) * 0.05, y: Math.min(depth, 3) * 14, rotate: restRotate, opacity: 1 }}
|
||||
variants={exitVariants}
|
||||
exit="leave"
|
||||
transition={{ type: "spring", bounce: 0, duration: 0.45 }}
|
||||
>
|
||||
<motion.div
|
||||
ref={cardRef}
|
||||
className={`h-full w-full ${obj.kind === "sticker" ? "rounded-full" : "rounded-2xl"} shadow-[6px_6px_0_rgb(var(--plakat-ink))] ${isTop ? "cursor-grab" : "pointer-events-none"}`}
|
||||
style={{ x, rotate: dragRotate, touchAction: "pan-y" }}
|
||||
drag={isTop ? "x" : false}
|
||||
dragSnapToOrigin
|
||||
dragElastic={0.9}
|
||||
onPointerDown={() => {
|
||||
dragged.current = false;
|
||||
}}
|
||||
onDragStart={() => {
|
||||
dragged.current = true;
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
onTap={isTop ? () => !dragged.current && openIt() : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (isTop && e.key === " ") {
|
||||
e.preventDefault();
|
||||
openIt();
|
||||
}
|
||||
}}
|
||||
tabIndex={isTop ? 0 : -1}
|
||||
role="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-label={labelFor(obj, labels)}
|
||||
aria-hidden={!isTop}
|
||||
>
|
||||
<ObjectFace obj={obj} labels={labels} />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getProjects } from "@/lib/directus";
|
||||
import Board from "./Board";
|
||||
import Deck from "./Deck";
|
||||
import { PROJECT_COLORS, type TischLabels, type TischObject } from "./types";
|
||||
|
||||
interface TischSectionProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
// Abgabe der Masterarbeit, Grundlage für das Ticket
|
||||
const THESIS_DEADLINE = new Date("2027-02-15T12:00:00+01:00");
|
||||
|
||||
/**
|
||||
* Der Tisch: Projekte und ein paar Dinge aus meinem Alltag als Objekte.
|
||||
* Desktop zeigt den freien Tisch, das Handy einen Kartenstapel. Beides wird
|
||||
* serverseitig gerendert und per CSS umgeschaltet, damit nichts springt.
|
||||
*/
|
||||
export default async function TischSection({ locale }: TischSectionProps) {
|
||||
const t = await getTranslations({ locale, namespace: "home.tisch" });
|
||||
const projects = (await getProjects(locale, { published: true, limit: 6 }).catch(() => null)) ?? [];
|
||||
const daysLeft = Math.max(0, Math.ceil((THESIS_DEADLINE.getTime() - Date.now()) / 86_400_000));
|
||||
|
||||
const labels: TischLabels = {
|
||||
hintBoard: t("hintBoard"),
|
||||
hintDeck: t("hintDeck"),
|
||||
tidy: t("tidy"),
|
||||
mess: t("mess"),
|
||||
flip: t("flip"),
|
||||
details: t("details"),
|
||||
prev: t("prev"),
|
||||
next: t("next"),
|
||||
ticketTitle: t("ticketTitle"),
|
||||
ticketDays: t("ticketDays"),
|
||||
ticketJob: t("ticketJob"),
|
||||
ticketStub: t("ticketStub"),
|
||||
serverTitle: t("serverTitle"),
|
||||
servers: [
|
||||
{ name: t("servers.ingress.name"), detail: t("servers.ingress.detail") },
|
||||
{ name: t("servers.mail.name"), detail: t("servers.mail.detail") },
|
||||
{ name: t("servers.home.name"), detail: t("servers.home.detail") },
|
||||
],
|
||||
serverNote: t("serverNote"),
|
||||
bookLabel: t("bookLabel"),
|
||||
bookFallback: t("bookFallback"),
|
||||
photoCaption: t("photoCaption"),
|
||||
sticker: t("sticker"),
|
||||
};
|
||||
|
||||
const projectObjects: TischObject[] = projects.map((p, i) => {
|
||||
const color = PROJECT_COLORS[i % PROJECT_COLORS.length];
|
||||
return {
|
||||
kind: "project",
|
||||
id: `project-${p.slug}`,
|
||||
index: i,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
year: p.created_at ? String(new Date(p.created_at).getFullYear()) : "",
|
||||
tags: p.tags?.length ? p.tags : p.technologies ?? [],
|
||||
href: `/${locale}/projects/${p.slug}`,
|
||||
color: color.bg,
|
||||
textColor: color.text,
|
||||
liveUrl: p.live_url && p.live_url !== "#" ? p.live_url : undefined,
|
||||
githubUrl: p.github_url && p.github_url !== "#" ? p.github_url : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Reihenfolge = Startplätze auf dem Tisch; Projekte zuerst, damit sie oben liegen
|
||||
const objects: TischObject[] = [
|
||||
...projectObjects,
|
||||
{ kind: "ticket", id: "ticket", daysLeft },
|
||||
{ kind: "server", id: "server" },
|
||||
{ kind: "book", id: "book", href: `/${locale}/books` },
|
||||
{ kind: "photo", id: "photo" },
|
||||
{ kind: "sticker", id: "sticker" },
|
||||
];
|
||||
// Im Stapel wäre der runde Sticker eine Ellipse und wiederholt nur den Claim
|
||||
const deckObjects = objects.filter((o) => o.kind !== "sticker");
|
||||
|
||||
return (
|
||||
<section id="projects" className="border-b-2 border-ink pb-16 pt-14 md:pt-16" aria-labelledby="tisch-title">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 px-4 pb-4 sm:px-6 lg:px-10">
|
||||
<h2
|
||||
id="tisch-title"
|
||||
className="font-display text-[clamp(2.4rem,6vw,5rem)] font-extrabold leading-[0.92] tracking-[-0.045em] [font-variation-settings:'wdth'_82]"
|
||||
>
|
||||
{t("title")}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
className="inline-flex items-center gap-2 rounded-full px-5 py-3 text-[15px] 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]"
|
||||
>
|
||||
{t("allProjects")} <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Board objects={objects} labels={labels} />
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<Deck objects={deckObjects} labels={labels} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Aktuelles Buch aus Hardcover (über n8n). Tisch, Stapel und Lese-Fenster teilen sich eine Anfrage.
|
||||
|
||||
export interface CurrentBook {
|
||||
title: string;
|
||||
authors: string[];
|
||||
image: string | null;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// Tisch und Stapel zeigen das Buch beide; so wird es nur einmal geladen
|
||||
let bookRequest: Promise<CurrentBook | null> | null = null;
|
||||
export function loadCurrentBook(): Promise<CurrentBook | null> {
|
||||
if (!bookRequest) {
|
||||
bookRequest = fetch("/api/n8n/hardcover/currently-reading")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
const list = data?.currentlyReading;
|
||||
const first = Array.isArray(list) ? list[0] : list;
|
||||
return first ?? null;
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
return bookRequest;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// Objekte auf dem Tisch. Der Server baut die Liste, die Client-Komponenten zeichnen sie.
|
||||
|
||||
export interface TischProject {
|
||||
kind: "project";
|
||||
id: string;
|
||||
index: number;
|
||||
title: string;
|
||||
description: string;
|
||||
year: string;
|
||||
tags: string[];
|
||||
href: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
liveUrl?: string;
|
||||
githubUrl?: string;
|
||||
}
|
||||
|
||||
export interface TischTicket {
|
||||
kind: "ticket";
|
||||
id: "ticket";
|
||||
daysLeft: number;
|
||||
}
|
||||
|
||||
export interface TischServer {
|
||||
kind: "server";
|
||||
id: "server";
|
||||
}
|
||||
|
||||
export interface TischBook {
|
||||
kind: "book";
|
||||
id: "book";
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface TischPhoto {
|
||||
kind: "photo";
|
||||
id: "photo";
|
||||
}
|
||||
|
||||
export interface TischSticker {
|
||||
kind: "sticker";
|
||||
id: "sticker";
|
||||
}
|
||||
|
||||
export type TischObject = TischProject | TischTicket | TischServer | TischBook | TischPhoto | TischSticker;
|
||||
|
||||
export interface TischLabels {
|
||||
hintBoard: string;
|
||||
hintDeck: string;
|
||||
tidy: string;
|
||||
mess: string;
|
||||
flip: string;
|
||||
details: string;
|
||||
prev: string;
|
||||
next: string;
|
||||
ticketTitle: string;
|
||||
ticketDays: string;
|
||||
ticketJob: string;
|
||||
ticketStub: string;
|
||||
serverTitle: string;
|
||||
servers: { name: string; detail: string }[];
|
||||
serverNote: string;
|
||||
bookLabel: string;
|
||||
bookFallback: string;
|
||||
photoCaption: string;
|
||||
sticker: string;
|
||||
}
|
||||
|
||||
// Breite und Höhe je Objektart auf dem Desktop-Tisch (px)
|
||||
export const BOARD_SIZE: Record<TischObject["kind"], { w: number; h: number }> = {
|
||||
project: { w: 236, h: 312 },
|
||||
ticket: { w: 320, h: 180 },
|
||||
server: { w: 270, h: 290 },
|
||||
book: { w: 176, h: 262 },
|
||||
photo: { w: 210, h: 250 },
|
||||
sticker: { w: 180, h: 180 },
|
||||
};
|
||||
|
||||
// Startpositionen: Anteil der Tischbreite, Abstand von oben in px, Neigung in Grad
|
||||
export const BOARD_SPOTS: { x: number; y: number; r: number }[] = [
|
||||
{ x: 0.02, y: 60, r: -6 },
|
||||
{ x: 0.23, y: 120, r: 4 },
|
||||
{ x: 0.45, y: 50, r: -3 },
|
||||
{ x: 0.67, y: 110, r: 6 },
|
||||
{ x: 0.04, y: 480, r: 5 },
|
||||
{ x: 0.28, y: 510, r: -5 },
|
||||
{ x: 0.52, y: 440, r: 3 },
|
||||
{ x: 0.8, y: 60, r: 8 },
|
||||
{ x: 0.76, y: 450, r: -7 },
|
||||
{ x: 0.42, y: 800, r: 7 },
|
||||
{ x: 0.12, y: 800, r: -4 },
|
||||
];
|
||||
|
||||
// Farbfelder für Projektkarten, abwechselnd hell und dunkel
|
||||
export const PROJECT_COLORS: { bg: string; text: string }[] = [
|
||||
{ bg: "#2f5bea", text: "#f6f3f1" },
|
||||
{ bg: "#1d1b3d", text: "#f6f3f1" },
|
||||
{ bg: "#0f6e66", text: "#f6f3f1" },
|
||||
{ bg: "#f2b531", text: "#140c0b" },
|
||||
{ bg: "#140c0b", text: "#e8402f" },
|
||||
{ bg: "#3f8a4a", text: "#f6f3f1" },
|
||||
];
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { WindowRequest } from "../fenster/types";
|
||||
import type { TischLabels, TischObject } from "./types";
|
||||
|
||||
/** Welches Fenster ein Objekt auf dem Tisch öffnet */
|
||||
export function windowFor(obj: TischObject): WindowRequest {
|
||||
switch (obj.kind) {
|
||||
case "project":
|
||||
return { kind: "project", project: obj };
|
||||
case "ticket":
|
||||
return { kind: "now" };
|
||||
case "server":
|
||||
case "sticker":
|
||||
return { kind: "server" };
|
||||
case "book":
|
||||
return { kind: "read" };
|
||||
case "photo":
|
||||
return { kind: "about" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Vorlesetext für das Objekt als Knopf */
|
||||
export function labelFor(obj: TischObject, labels: TischLabels): string {
|
||||
switch (obj.kind) {
|
||||
case "project":
|
||||
return obj.title;
|
||||
case "ticket":
|
||||
return labels.ticketTitle;
|
||||
case "server":
|
||||
return labels.serverTitle;
|
||||
case "book":
|
||||
return labels.bookLabel;
|
||||
case "photo":
|
||||
return labels.photoCaption;
|
||||
case "sticker":
|
||||
return labels.sticker;
|
||||
}
|
||||
}
|
||||
@@ -33,108 +33,6 @@
|
||||
"ctaWork": "Projekte ansehen",
|
||||
"ctaContact": "Schreib mir"
|
||||
},
|
||||
"tisch": {
|
||||
"title": "Der Tisch.",
|
||||
"allProjects": "Alle Projekte",
|
||||
"hintBoard": "Alles hier lässt sich anfassen. Werfen, stapeln, antippen für Details.",
|
||||
"hintDeck": "Wisch die Karten zur Seite, tipp für Details.",
|
||||
"tidy": "Aufräumen",
|
||||
"mess": "Durcheinander",
|
||||
"flip": "antippen für Details",
|
||||
"details": "Zum Projekt",
|
||||
"prev": "Vorherige Karte",
|
||||
"next": "Nächste Karte",
|
||||
"ticketTitle": "Masterarbeit · Abgabe 15.02.2027",
|
||||
"ticketDays": "Tage bis zur Abgabe",
|
||||
"ticketJob": "ab 02/2027 fest als M365-Administrator",
|
||||
"ticketStub": "Jetzt",
|
||||
"serverTitle": "Was bei mir läuft",
|
||||
"servers": {
|
||||
"ingress": {
|
||||
"name": "netcup · Eingang",
|
||||
"detail": "Traefik, Dokploy, diese Seite"
|
||||
},
|
||||
"mail": {
|
||||
"name": "IONOS · Post und Fotos",
|
||||
"detail": "mailcow, Immich"
|
||||
},
|
||||
"home": {
|
||||
"name": "Zuhause · Raspberry Pi",
|
||||
"detail": "Home Assistant, Paperless"
|
||||
}
|
||||
},
|
||||
"serverNote": "Gesichert mit CrowdSec, tägliche Backups mit restic.",
|
||||
"bookLabel": "Lese gerade",
|
||||
"bookFallback": "Zur Bibliothek",
|
||||
"photoCaption": "Das bin ich.",
|
||||
"sticker": "Läuft auf eigenen Servern."
|
||||
},
|
||||
"windows": {
|
||||
"close": "Schließen",
|
||||
"minimize": "Ablegen",
|
||||
"openWindows": "Offene Fenster",
|
||||
"projectPage": "Projektseite",
|
||||
"source": "Quellcode",
|
||||
"live": "Live ansehen",
|
||||
"stack": "Technik",
|
||||
"year": "Jahr",
|
||||
"now": {
|
||||
"title": "jetzt.txt · Stand Oktober 2026",
|
||||
"days": "Tage bis zur Abgabe der Masterarbeit",
|
||||
"study": {
|
||||
"label": "Studium",
|
||||
"text": "Federated Reinforcement Learning für einen Logistik-Hub, Abgabe Mitte Februar 2027"
|
||||
},
|
||||
"work": {
|
||||
"label": "Arbeit",
|
||||
"text": "IT bei einem Unternehmen in Osnabrück, ab Februar 2027 fest als Microsoft-365-Administrator"
|
||||
},
|
||||
"side": {
|
||||
"label": "Nebenher",
|
||||
"text": "nightly, eine App für Gedanken zwischen 2 und 5 Uhr nachts"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"title": "server · alle grün",
|
||||
"headline": "Drei Rechner, ein VPN."
|
||||
},
|
||||
"read": {
|
||||
"title": "lesen · aus Hardcover",
|
||||
"progress": "gelesen",
|
||||
"empty": "Gerade liegt kein Buch auf dem Nachttisch.",
|
||||
"library": "Zur Bibliothek"
|
||||
},
|
||||
"about": {
|
||||
"title": "über mich",
|
||||
"p1": "Aufgewachsen bin ich in Lingen, inzwischen wohne ich in Osnabrück. Angefangen hat alles mit einem Raspberry Pi und der Frage, ob ich meine Fotos nicht selbst speichern kann. Daraus sind drei Server geworden.",
|
||||
"p2": "Wenn ich nicht am Rechner sitze, bin ich dienstags bei Body Attack, im Winter auf der Piste oder mit Freunden bei einem Brettspiel.",
|
||||
"facts": {
|
||||
"laptop": {
|
||||
"label": "Arbeitsrechner",
|
||||
"value": "MacBook Pro M4 Pro"
|
||||
},
|
||||
"pc": {
|
||||
"label": "Zum Spielen",
|
||||
"value": "PC mit RTX 3080"
|
||||
},
|
||||
"study": {
|
||||
"label": "Studium",
|
||||
"value": "Master Informatik"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Sprachen",
|
||||
"value": "Deutsch, Englisch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "kontakt",
|
||||
"body": "Für Projekte, Fragen zu meinem Setup oder einfach so. Ich antworte normalerweise innerhalb von ein, zwei Tagen.",
|
||||
"copy": "Adresse kopieren",
|
||||
"copied": "Kopiert",
|
||||
"form": "Zum Formular"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Hinter den Kulissen",
|
||||
"p1": "Schon seit ich meinen ersten eigenen Server aufgesetzt habe, lässt mich das Thema Infrastruktur nicht mehr los. Als Student in Osnabrück verbringe ich meine Zeit am liebsten damit, moderne Web-Apps mit Next.js zu bauen oder mobile Lösungen mit Flutter zu entwickeln.",
|
||||
|
||||
@@ -33,108 +33,6 @@
|
||||
"ctaWork": "See projects",
|
||||
"ctaContact": "Write me"
|
||||
},
|
||||
"tisch": {
|
||||
"title": "The desk.",
|
||||
"allProjects": "All projects",
|
||||
"hintBoard": "Everything here can be touched. Throw it, stack it, tap for details.",
|
||||
"hintDeck": "Swipe the cards aside, tap for details.",
|
||||
"tidy": "Tidy up",
|
||||
"mess": "Mess it up",
|
||||
"flip": "tap for details",
|
||||
"details": "Open project",
|
||||
"prev": "Previous card",
|
||||
"next": "Next card",
|
||||
"ticketTitle": "Master's thesis · due 15 Feb 2027",
|
||||
"ticketDays": "days until submission",
|
||||
"ticketJob": "from 02/2027: M365 administrator",
|
||||
"ticketStub": "Now",
|
||||
"serverTitle": "What I run",
|
||||
"servers": {
|
||||
"ingress": {
|
||||
"name": "netcup · front door",
|
||||
"detail": "Traefik, Dokploy, this site"
|
||||
},
|
||||
"mail": {
|
||||
"name": "IONOS · mail and photos",
|
||||
"detail": "mailcow, Immich"
|
||||
},
|
||||
"home": {
|
||||
"name": "Home · Raspberry Pi",
|
||||
"detail": "Home Assistant, Paperless"
|
||||
}
|
||||
},
|
||||
"serverNote": "Guarded by CrowdSec, daily backups with restic.",
|
||||
"bookLabel": "Reading now",
|
||||
"bookFallback": "To the library",
|
||||
"photoCaption": "That's me.",
|
||||
"sticker": "Runs on my own servers."
|
||||
},
|
||||
"windows": {
|
||||
"close": "Close",
|
||||
"minimize": "Minimize",
|
||||
"openWindows": "Open windows",
|
||||
"projectPage": "Project page",
|
||||
"source": "Source",
|
||||
"live": "See it live",
|
||||
"stack": "Stack",
|
||||
"year": "Year",
|
||||
"now": {
|
||||
"title": "now.txt · October 2026",
|
||||
"days": "days until my master's thesis is due",
|
||||
"study": {
|
||||
"label": "Study",
|
||||
"text": "Federated reinforcement learning for a logistics hub, due mid-February 2027"
|
||||
},
|
||||
"work": {
|
||||
"label": "Work",
|
||||
"text": "IT at a company in Osnabrück, from February 2027 full-time as Microsoft 365 administrator"
|
||||
},
|
||||
"side": {
|
||||
"label": "On the side",
|
||||
"text": "nightly, an app for thoughts between 2 and 5 a.m."
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
"title": "servers · all green",
|
||||
"headline": "Three machines, one VPN."
|
||||
},
|
||||
"read": {
|
||||
"title": "reading · from Hardcover",
|
||||
"progress": "read",
|
||||
"empty": "No book on the nightstand right now.",
|
||||
"library": "To the library"
|
||||
},
|
||||
"about": {
|
||||
"title": "about me",
|
||||
"p1": "I grew up in Lingen and now live in Osnabrück. It all started with a Raspberry Pi and the question whether I could store my photos myself. That turned into three servers.",
|
||||
"p2": "When I'm not at the computer, I'm at Body Attack on Tuesdays, on the slopes in winter, or playing board games with friends.",
|
||||
"facts": {
|
||||
"laptop": {
|
||||
"label": "Work machine",
|
||||
"value": "MacBook Pro M4 Pro"
|
||||
},
|
||||
"pc": {
|
||||
"label": "For games",
|
||||
"value": "PC with RTX 3080"
|
||||
},
|
||||
"study": {
|
||||
"label": "Study",
|
||||
"value": "Master's in computer science"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Languages",
|
||||
"value": "German, English"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "contact",
|
||||
"body": "For projects, questions about my setup or just because. I usually reply within a day or two.",
|
||||
"copy": "Copy address",
|
||||
"copied": "Copied",
|
||||
"form": "To the form"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Behind the Code",
|
||||
"p1": "Ever since I set up my first home server, I've been hooked on infrastructure. Currently studying in Osnabrück, I split my time between developing modern web apps with Next.js and building mobile experiences with Flutter.",
|
||||
|
||||
Generated
+1
-471
@@ -34,8 +34,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"redis": "^5.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul": "1.1.2"
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -2995,323 +2994,6 @@
|
||||
"@prisma/debug": "5.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
|
||||
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
|
||||
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
|
||||
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.3.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.2.1",
|
||||
"@radix-ui/react-focus-guards": "1.2.0",
|
||||
"@radix-ui/react-focus-scope": "1.3.1",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.2.0",
|
||||
"@radix-ui/react-presence": "1.1.11",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.3.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
|
||||
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
|
||||
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
|
||||
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
|
||||
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
|
||||
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
|
||||
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
|
||||
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
|
||||
@@ -5331,18 +5013,6 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
@@ -6718,12 +6388,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
@@ -8078,15 +7742,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-package-type": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
@@ -13006,75 +12661,6 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -14932,27 +14518,6 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-intl": {
|
||||
"version": "4.8.2",
|
||||
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",
|
||||
@@ -14974,28 +14539,6 @@
|
||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
@@ -15034,19 +14577,6 @@
|
||||
"node": ">=10.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
+1
-2
@@ -78,8 +78,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"redis": "^5.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul": "1.1.2"
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome >= 100",
|
||||
|
||||
@@ -96,9 +96,6 @@ export default {
|
||||
2: "rgb(var(--plakat-paper-2) / <alpha-value>)",
|
||||
},
|
||||
ok: "rgb(var(--plakat-ok) / <alpha-value>)",
|
||||
// Feste Objektfarben auf dem Tisch, unabhängig vom Theme
|
||||
polaroid: { DEFAULT: "#fbfaf8", ink: "#140c0b" },
|
||||
bookcover: { DEFAULT: "#2b1a14", text: "#f3e7dc" },
|
||||
// Warm brown palette
|
||||
cream: "#FAF8F3",
|
||||
sand: "#EFEBE9",
|
||||
|
||||
Reference in New Issue
Block a user