4 Commits

Author SHA1 Message Date
denshooter f0e257ad19 feat: plakat redesign phase 3 (windows on desktop, sheets on mobile)
- 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>
2026-10-15 23:49:21 +02:00
denshooter 62f2fb7742 feat: plakat redesign phase 2 (desk with throwable objects, mobile card deck) (#75)
- New "Tisch" section replaces the old projects block on the home page
- Objects: project cards from Directus (flip on tap, link to detail page),
  thesis ticket with countdown, server card, currently-reading book from
  Hardcover (with skeleton), polaroid photo, sticker
- Desktop: Framer Motion drag with momentum, elastic table edges, tilt that
  follows throw velocity, bring-to-front, "Tidy up" and "Mess it up"
- Mobile: card deck, swipe the top card away (distance or velocity),
  prev/next buttons, live counter, tap to flip
- Both layouts are server rendered and switched by CSS, so content is
  visible without JavaScript and nothing jumps on hydration
- Taps after a drag no longer flip cards
- New named colors polaroid/bookcover instead of raw hex classes
- Texts in de/en, i18n key check extended

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:49:06 +02:00
denshooter 96ec2d867f feat: plakat redesign phase 1 (tokens, fonts, poster hero, header) (#74)
- New design tokens: signal red, ink and paper as RGB channels; ink and paper
  swap roles in dark mode so every component inverts cleanly
- Fonts via next/font: Bricolage Grotesque (with wdth/opsz axes), Geist,
  Geist Mono; drop Inter and Playfair
- Poster hero: kicker, giant DK0 wordmark that reacts to pointer distance
  (weight and width through a damped spring, desktop only, off for reduced
  motion), claim as h1, intro with thesis countdown, marquee strip
- Header in poster style: transparent over the red hero, paper strip with
  ink rule when scrolled or on subpages; drawer menu restyled
- Remove wavy SVG separators, CSS shader gradient, background blobs and
  unused providers that caused the pink/purple bands between sections
- Drop the global p/span/div color override so colors inherit properly
- New hero texts in de/en, tests and i18n key check updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:48:39 +02:00
denshooter 02914d6145 feat: polish motion with stronger easing, press feedback and reduced motion (#73)
- Override Tailwind ease-out/ease-in-out with stronger curves, add ease-drawer
- hoverOnlyWhenSupported so hover styles never stick on touch devices
- Replace transition-all with explicit properties across the public UI
- Press feedback via active:scale-[0.97] instead of hover:scale-105
- Global prefers-reduced-motion handling (CSS + MotionConfig reducedMotion="user")
- Mobile menu is a real drawer: stays mounted, inert when closed, Escape closes
- Progress and music bars animate scaleX/scaleY instead of width/height
- Fix undefined animate-spin-slow, contain hero CTA shine with overflow-hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:48:08 +02:00
19 changed files with 1413 additions and 113 deletions
+1
View File
@@ -138,6 +138,7 @@ const STATIC_KEYS: Record<string, Record<string, string[]>> = {
consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] }, consent: { ConsentBanner: ["title", "description", "essential", "chat", "alwaysOn", "acceptAll", "acceptSelected", "rejectAll", "hide"] },
"home.hero": { Hero: ["kicker", "claim", "intro", "countdown", "ctaWork", "ctaContact"] }, "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.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": { About: ["title", "p1", "p2", "p3", "funFactTitle", "funFactBody", "hobbiesTitle"] },
"home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] }, "home.about.activity": { ActivityFeed: ["idleStatus", "codingNow", "gaming", "inGame", "listening"] },
"home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] }, "home.about.currentlyReading": { CurrentlyReading: ["title", "progress"] },
+6 -2
View File
@@ -1,6 +1,8 @@
import Header from "../components/Header.server"; import Header from "../components/Header.server";
import Hero from "../components/Hero"; import Hero from "../components/Hero";
import TischSection from "../components/tisch/TischSection"; import TischSection from "../components/tisch/TischSection";
import WindowProvider from "../components/fenster/WindowProvider";
import { getWindowLabels } from "../components/fenster/getWindowLabels";
import ScrollFadeIn from "../components/ScrollFadeIn"; import ScrollFadeIn from "../components/ScrollFadeIn";
import Script from "next/script"; import Script from "next/script";
import { import {
@@ -20,10 +22,11 @@ interface HomePageServerProps {
export default async function HomePageServer({ locale }: HomePageServerProps) { export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations (hero translations handled by Hero server component) // Parallel laden aller Translations (hero translations handled by Hero server component)
const [aboutT, contactT, footerT] = await Promise.all([ const [aboutT, contactT, footerT, windowLabels] = await Promise.all([
getAboutTranslations(locale), getAboutTranslations(locale),
getContactTranslations(locale), getContactTranslations(locale),
getFooterTranslations(locale), getFooterTranslations(locale),
getWindowLabels(locale),
]); ]);
return ( return (
@@ -86,6 +89,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
}} }}
/> />
<Header locale={locale} /> <Header locale={locale} />
<WindowProvider labels={windowLabels}>
<main className="relative"> <main className="relative">
<Hero locale={locale} /> <Hero locale={locale} />
<TischSection locale={locale} /> <TischSection locale={locale} />
@@ -94,7 +98,6 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
<AboutClient locale={locale} translations={aboutT} /> <AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn> </ScrollFadeIn>
<ScrollFadeIn> <ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} /> <ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn> </ScrollFadeIn>
@@ -102,6 +105,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
<ScrollFadeIn> <ScrollFadeIn>
<FooterClient locale={locale} translations={footerT} /> <FooterClient locale={locale} translations={footerT} />
</ScrollFadeIn> </ScrollFadeIn>
</WindowProvider>
</div> </div>
); );
} }
+4 -2
View File
@@ -1,5 +1,6 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import KineticWordmark from "./KineticWordmark"; import KineticWordmark from "./KineticWordmark";
import OpenWindowLink from "./fenster/OpenWindowLink";
interface HeroProps { interface HeroProps {
locale: string; locale: string;
@@ -39,12 +40,13 @@ export default async function Hero({ locale }: HeroProps) {
> >
{t("ctaWork")} <span aria-hidden="true">↓</span> {t("ctaWork")} <span aria-hidden="true">↓</span>
</a> </a>
<a <OpenWindowLink
href="#contact" 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]" 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")} {t("ctaContact")}
</a> </OpenWindowLink>
</div> </div>
</div> </div>
</div> </div>
+29
View File
@@ -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);
}}
/>
);
}
+247
View File
@@ -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>
</>
);
}
+276
View File
@@ -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>
);
}
+55
View File
@@ -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"),
},
};
}
+55
View File
@@ -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;
};
}
+18 -16
View File
@@ -12,6 +12,8 @@ import {
type MotionValue, type MotionValue,
} from "framer-motion"; } from "framer-motion";
import ObjectFace from "./ObjectFace"; 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"; import { BOARD_SIZE, BOARD_SPOTS, type TischLabels, type TischObject } from "./types";
interface Handle { interface Handle {
@@ -138,7 +140,6 @@ interface BoardItemProps {
function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardItemProps) { function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardItemProps) {
const reduce = useReducedMotion(); const reduce = useReducedMotion();
const [flipped, setFlipped] = useState(false);
const [lifted, setLifted] = useState(false); const [lifted, setLifted] = useState(false);
// Framer meldet nach einem Wurf trotzdem einen Tap; der soll die Karte nicht umdrehen // Framer meldet nach einem Wurf trotzdem einen Tap; der soll die Karte nicht umdrehen
const dragged = useRef(false); const dragged = useRef(false);
@@ -151,12 +152,17 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
const rotate = useTransform(() => base.get() + (reduce ? 0 : tilt.get())); const rotate = useTransform(() => base.get() + (reduce ? 0 : tilt.get()));
const { w, h } = BOARD_SIZE[obj.kind]; const { w, h } = BOARD_SIZE[obj.kind];
const canFlip = obj.kind === "project"; 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"; const radius = { sticker: "rounded-full", photo: "rounded-md", book: "rounded-[6px_14px_14px_6px]" }[obj.kind as string] ?? "rounded-2xl";
return ( return (
<motion.div <motion.div
ref={(el) => register(el ? { el, x, y, base, spotRotation: spot.r } : null)} ref={(el) => {
elRef.current = el;
register(el ? { el, x, y, base, spotRotation: spot.r } : null);
}}
drag drag
dragConstraints={tableRef} dragConstraints={tableRef}
dragElastic={0.18} dragElastic={0.18}
@@ -171,21 +177,17 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
setLifted(true); setLifted(true);
}} }}
onDragEnd={() => setLifted(false)} onDragEnd={() => setLifted(false)}
onTap={canFlip ? () => !dragged.current && setFlipped((f) => !f) : undefined} onTap={() => !dragged.current && openIt()}
onKeyDown={ onKeyDown={(e) => {
canFlip
? (e) => {
if (e.key === " ") { if (e.key === " ") {
e.preventDefault(); e.preventDefault();
setFlipped((f) => !f); openIt();
} }
} }}
: undefined tabIndex={0}
} role="button"
tabIndex={canFlip ? 0 : undefined} aria-haspopup="dialog"
role={canFlip ? "button" : undefined} aria-label={labelFor(obj, labels)}
aria-pressed={canFlip ? flipped : undefined}
aria-label={canFlip && obj.kind === "project" ? obj.title : undefined}
className={`absolute cursor-grab touch-none select-none active:cursor-grabbing ${radius} transition-shadow duration-200 ease-out ${ 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))]" lifted ? "shadow-[14px_16px_0_rgb(var(--plakat-ink))]" : "shadow-[6px_6px_0_rgb(var(--plakat-ink))]"
}`} }`}
@@ -200,7 +202,7 @@ function BoardItem({ obj, labels, spot, z, tableRef, onFront, register }: BoardI
zIndex: z, zIndex: z,
}} }}
> >
<ObjectFace obj={obj} labels={labels} flipped={flipped} /> <ObjectFace obj={obj} labels={labels} />
</motion.div> </motion.div>
); );
} }
+18 -7
View File
@@ -3,6 +3,8 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo, type Variants } from "framer-motion"; import { AnimatePresence, motion, useMotionValue, useTransform, type PanInfo, type Variants } from "framer-motion";
import ObjectFace from "./ObjectFace"; import ObjectFace from "./ObjectFace";
import { useOpenWindow } from "../fenster/WindowProvider";
import { labelFor, windowFor } from "./windowFor";
import type { TischLabels, TischObject } from "./types"; import type { TischLabels, TischObject } from "./types";
interface DeckProps { interface DeckProps {
@@ -101,12 +103,13 @@ interface DeckCardProps {
} }
function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardProps) { function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardProps) {
const [flipped, setFlipped] = useState(false);
const dragged = useRef(false); const dragged = useRef(false);
const cardRef = useRef<HTMLDivElement>(null);
const openWindow = useOpenWindow();
const openIt = () => openWindow?.(windowFor(obj), cardRef.current);
const x = useMotionValue(0); const x = useMotionValue(0);
const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]); const dragRotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]);
const isTop = depth === 0; const isTop = depth === 0;
const canFlip = obj.kind === "project";
const restRotate = depth === 0 ? 0 : depth % 2 ? 3 : -3; const restRotate = depth === 0 ? 0 : depth % 2 ? 3 : -3;
const handleDragEnd = (_: unknown, info: PanInfo) => { const handleDragEnd = (_: unknown, info: PanInfo) => {
@@ -126,6 +129,7 @@ function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardP
transition={{ type: "spring", bounce: 0, duration: 0.45 }} transition={{ type: "spring", bounce: 0, duration: 0.45 }}
> >
<motion.div <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"}`} 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" }} style={{ x, rotate: dragRotate, touchAction: "pan-y" }}
drag={isTop ? "x" : false} drag={isTop ? "x" : false}
@@ -138,13 +142,20 @@ function DeckCard({ obj, labels, depth, enteringFromLeft, onDismiss }: DeckCardP
dragged.current = true; dragged.current = true;
}} }}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onTap={isTop && canFlip ? () => !dragged.current && setFlipped((f) => !f) : undefined} onTap={isTop ? () => !dragged.current && openIt() : undefined}
tabIndex={isTop && canFlip ? 0 : -1} onKeyDown={(e) => {
role={canFlip ? "button" : undefined} if (isTop && e.key === " ") {
aria-pressed={canFlip ? flipped : undefined} e.preventDefault();
openIt();
}
}}
tabIndex={isTop ? 0 : -1}
role="button"
aria-haspopup="dialog"
aria-label={labelFor(obj, labels)}
aria-hidden={!isTop} aria-hidden={!isTop}
> >
<ObjectFace obj={obj} labels={labels} flipped={flipped} /> <ObjectFace obj={obj} labels={labels} />
</motion.div> </motion.div>
</motion.div> </motion.div>
); );
+4 -54
View File
@@ -4,11 +4,11 @@ import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import type { TischLabels, TischObject } from "./types"; import type { TischLabels, TischObject } from "./types";
import { loadCurrentBook, type CurrentBook } from "./currentBook";
interface ObjectFaceProps { interface ObjectFaceProps {
obj: TischObject; obj: TischObject;
labels: TischLabels; labels: TischLabels;
flipped?: boolean;
} }
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
@@ -17,20 +17,11 @@ const pad = (n: number) => String(n).padStart(2, "0");
const edge = "h-full w-full overflow-hidden rounded-2xl border-2 border-ink"; 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). */ /** Zeichnet ein Objekt. Größe und Position bestimmt der Container (Tisch oder Stapel). */
export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceProps) { export default function ObjectFace({ obj, labels }: ObjectFaceProps) {
switch (obj.kind) { switch (obj.kind) {
case "project": case "project":
return ( return (
<div className="h-full w-full [perspective:1200px]"> <div className={`${edge} flex flex-col justify-between p-4`} style={{ background: obj.color, color: obj.textColor }}>
<div
className={`relative h-full w-full rounded-2xl transition-transform duration-[560ms] ease-in-out [transform-style:preserve-3d] ${
flipped ? "[transform:rotateY(180deg)]" : ""
}`}
>
<div
className={`${edge} absolute inset-0 flex flex-col justify-between p-4 [backface-visibility:hidden]`}
style={{ background: obj.color, color: obj.textColor }}
>
<div className="flex justify-between font-mono text-xs"> <div className="flex justify-between font-mono text-xs">
<span>({pad(obj.index + 1)})</span> <span>({pad(obj.index + 1)})</span>
<span>{obj.year}</span> <span>{obj.year}</span>
@@ -40,25 +31,7 @@ export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceP
</h3> </h3>
<div className="font-mono text-xs leading-snug"> <div className="font-mono text-xs leading-snug">
<div>{obj.tags.slice(0, 3).join(" · ")}</div> <div>{obj.tags.slice(0, 3).join(" · ")}</div>
<div className="mt-2 opacity-70">{labels.flip} ↻</div> <div className="mt-2 opacity-70">{labels.flip} ↗</div>
</div>
</div>
<div
className={`${edge} absolute inset-0 flex flex-col justify-between bg-paper p-4 text-ink [backface-visibility:hidden] [transform:rotateY(180deg)]`}
>
<div className="flex justify-between font-mono text-xs">
<span className="truncate pr-2">{obj.title}</span>
<span>{obj.year}</span>
</div>
<p className="line-clamp-6 text-[15px] leading-snug">{obj.description}</p>
<Link
href={obj.href}
className="inline-flex w-fit items-center gap-1.5 rounded-full bg-ink px-3.5 py-2 text-sm font-semibold text-paper transition-transform duration-150 ease-out active:scale-[0.97]"
onPointerDownCapture={(e) => e.stopPropagation()}
>
{labels.details} <span aria-hidden="true">→</span>
</Link>
</div>
</div> </div>
</div> </div>
); );
@@ -121,29 +94,6 @@ export default function ObjectFace({ obj, labels, flipped = false }: ObjectFaceP
} }
} }
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;
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;
}
function BookFace({ href, labels }: { href: string; labels: TischLabels }) { function BookFace({ href, labels }: { href: string; labels: TischLabels }) {
const [book, setBook] = useState<CurrentBook | null | undefined>(undefined); const [book, setBook] = useState<CurrentBook | null | undefined>(undefined);
+2
View File
@@ -61,6 +61,8 @@ export default async function TischSection({ locale }: TischSectionProps) {
href: `/${locale}/projects/${p.slug}`, href: `/${locale}/projects/${p.slug}`,
color: color.bg, color: color.bg,
textColor: color.text, 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,
}; };
}); });
+24
View File
@@ -0,0 +1,24 @@
// 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;
}
+2
View File
@@ -11,6 +11,8 @@ export interface TischProject {
href: string; href: string;
color: string; color: string;
textColor: string; textColor: string;
liveUrl?: string;
githubUrl?: string;
} }
export interface TischTicket { export interface TischTicket {
+37
View File
@@ -0,0 +1,37 @@
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;
}
}
+69 -3
View File
@@ -36,11 +36,11 @@
"tisch": { "tisch": {
"title": "Der Tisch.", "title": "Der Tisch.",
"allProjects": "Alle Projekte", "allProjects": "Alle Projekte",
"hintBoard": "Alles hier lässt sich anfassen. Werfen, stapeln, Karten umdrehen.", "hintBoard": "Alles hier lässt sich anfassen. Werfen, stapeln, antippen für Details.",
"hintDeck": "Wisch die Karten zur Seite, tipp zum Umdrehen.", "hintDeck": "Wisch die Karten zur Seite, tipp für Details.",
"tidy": "Aufräumen", "tidy": "Aufräumen",
"mess": "Durcheinander", "mess": "Durcheinander",
"flip": "tippen zum Umdrehen", "flip": "antippen für Details",
"details": "Zum Projekt", "details": "Zum Projekt",
"prev": "Vorherige Karte", "prev": "Vorherige Karte",
"next": "Nächste Karte", "next": "Nächste Karte",
@@ -69,6 +69,72 @@
"photoCaption": "Das bin ich.", "photoCaption": "Das bin ich.",
"sticker": "Läuft auf eigenen Servern." "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": { "about": {
"title": "Hinter den Kulissen", "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.", "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.",
+69 -3
View File
@@ -36,11 +36,11 @@
"tisch": { "tisch": {
"title": "The desk.", "title": "The desk.",
"allProjects": "All projects", "allProjects": "All projects",
"hintBoard": "Everything here can be touched. Throw it, stack it, flip the cards.", "hintBoard": "Everything here can be touched. Throw it, stack it, tap for details.",
"hintDeck": "Swipe the cards aside, tap to flip.", "hintDeck": "Swipe the cards aside, tap for details.",
"tidy": "Tidy up", "tidy": "Tidy up",
"mess": "Mess it up", "mess": "Mess it up",
"flip": "tap to flip", "flip": "tap for details",
"details": "Open project", "details": "Open project",
"prev": "Previous card", "prev": "Previous card",
"next": "Next card", "next": "Next card",
@@ -69,6 +69,72 @@
"photoCaption": "That's me.", "photoCaption": "That's me.",
"sticker": "Runs on my own servers." "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": { "about": {
"title": "Behind the Code", "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.", "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.",
+471 -1
View File
@@ -34,7 +34,8 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"redis": "^5.8.2", "redis": "^5.8.2",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"tailwind-merge": "^2.6.0" "tailwind-merge": "^2.6.0",
"vaul": "1.1.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@@ -2994,6 +2995,323 @@
"@prisma/debug": "5.22.0" "@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": { "node_modules/@redis/bloom": {
"version": "5.10.0", "version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
@@ -5013,6 +5331,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0" "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": { "node_modules/aria-query": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -6388,6 +6718,12 @@
"node": ">=8" "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": { "node_modules/devlop": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -7742,6 +8078,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-package-type": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -12661,6 +13006,75 @@
"react": ">=18" "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": { "node_modules/read-cache": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -14518,6 +14932,27 @@
"requires-port": "^1.0.0" "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": { "node_modules/use-intl": {
"version": "4.8.2", "version": "4.8.2",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",
@@ -14539,6 +14974,28 @@
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" "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": { "node_modules/use-sync-external-store": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -14577,6 +15034,19 @@
"node": ">=10.12.0" "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": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+2 -1
View File
@@ -78,7 +78,8 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"redis": "^5.8.2", "redis": "^5.8.2",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"tailwind-merge": "^2.6.0" "tailwind-merge": "^2.6.0",
"vaul": "1.1.2"
}, },
"browserslist": [ "browserslist": [
"chrome >= 100", "chrome >= 100",