commit 3f9553cff4d1d49da2e6a054eaacef4dfcc424ef Author: kryptomrx Date: Mon May 18 16:05:11 2026 +0200 feat: initial commit — noz-studio web editor Full-featured web editor PWA for noz, built with Next.js and CodeMirror 6. Editor: - CM6-based Markdown editor with iA Writer aesthetic and Typora-style marker hiding - Live preview with KaTeX math, highlight.js syntax highlighting, wikilink rendering - Wikilink autocomplete via server note graph ([[ triggers suggestion list) - Floating selection toolbar, format toolbar, status bar - Split / editor-only / preview-only view modes with resizable panels - Visual frontmatter panel (no raw YAML editing required) Navigation: - Folder tree with topics, sub-topics, drafts section - Create / rename / move / delete notes and folders - Diagram editor (TOML definition → canvas visualization) Search: - ⌘K command palette with scoped search (@topic, @web, @draft) Preview: - Broken wikilink detection with visual indicator and toast notification - Fragment links: [[note#section]] navigates and scrolls to heading - In-page links: [[#section]] scrolls without switching notes - Heading anchors auto-generated from heading text Auth: - Token / Authentik SSO / both — driven by noosphere.toml - sessionStorage only (no localStorage — prevents credential leaks on shared devices) i18n: German + English diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36d5e68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Next.js build output +.next/ +out/ + +# Environment — Secrets, nie committen +.env.local +.env*.local + +# TypeScript cache +tsconfig.tsbuildinfo + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9673bcc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,241 @@ +# noz-studio — Projektübersicht für Claude + +> **Kommunikation:** Deutsch. Alle Konversationen, Commit-Messages, Kommentare und UI-Labels auf Deutsch. + +## Was ist noz-studio? + +noz-studio ist der Web-Editor für [noz](../noz) — ein vollständiger Markdown-Editor als PWA, optimiert für Desktop und Mobile (Touch). Gedacht für Situationen wo noz-cli nicht verfügbar ist (Arbeit, unterwegs, fremdes Gerät). + +**noz-studio ist ein Client.** Es schreibt keinen eigenen Content-Store — es spricht ausschließlich gegen die noz-API. Die einzige Verbindung zwischen noz und noz-studio sind API-Endpunkte. + +``` +noz (Server + API) ←────────────────────────── + ↑ ↑ ↑ + noz-cli noz (public) noz-studio + (Terminal) (Leser) (Web-Editor) +``` + +--- + +## Stack + +- **Next.js** (App Router) — gleicher Stack wie noz +- **CodeMirror 6** — Editor-Engine (MIT-Lizenz) — NUR als Engine, alles außen rum ist custom +- **Tailwind CSS v4** — OKLCH-Farben, gleiche Palette wie noz aber eigene Design-Sprache +- **PWA** — `manifest.json` + Service Worker — installierbar auf iOS/Android/Desktop + +--- + +## Design-Prinzipien + +- **iA Writer-Feel** — fokussiert, clean, Typografie im Vordergrund +- **Affine-like** — immer offen, kein "speichern"-Stress +- **Kein localStorage für Credentials** — Token in `sessionStorage` (Tab zu → Token weg, kein Datenleak auf Arbeitsrechnern) +- **noosphere.toml als Single Source of Truth** — Studio liest Config vom Server, nie hardcoden +- **CM6 ist swappable** — EditorEngine-Interface abstrahiert die Implementierung + +--- + +## Auth-System + +Konfiguriert in `noosphere.toml` des noz-Servers: + +```toml +[studio] +auth = "token" # "token" | "authentik" | "both" +allow_delete = true +allow_push = true +allow_rebuild = false +``` + +Studio macht beim Start einen **public Request ohne Token**: +``` +GET /api/studio/config → { auth: "token" | "authentik" | "both", name: "...", lang: "..." } +``` +Daraufhin zeigt der Login-Screen die passende UI (Token-Formular, Authentik-SSO-Button, oder beides). + +**Token-Handling:** +- Gespeichert in `sessionStorage` — nie `localStorage` +- Tab schließen = Token weg = kein Datenleak auf fremden Geräten +- Gleicher Token wie `[cli]` in `noosphere.toml` + +--- + +## Draft-System (3 Stufen) + +``` +[ Draft ] → [ In Review ] → [ Live ] +``` + +| Stufe | Speicherort | Sichtbar auf noz? | Wer sieht es? | +|-------|-------------|-------------------|---------------| +| **Draft** | `content/noosphere/_drafts/notiz.md` | Nein (Index-Builder überspringt `_`-Ordner) | Nur Studio | +| **In Review** | `content/noosphere/topic/notiz.md` + `draft: true` im Frontmatter | Nur via Authentik/Vault | Eingeloggte User | +| **Live** | `content/noosphere/topic/notiz.md` (kein draft-Flag) | Ja, öffentlich | Alle | + +Der **Draft-Button** im Editor-StatusBar toggelt zwischen den Stufen. Studio kümmert sich um: +- Datei verschieben (`_drafts/` → topic-Ordner) +- Frontmatter `draft: true/false` setzen +- Index-Rebuild triggern (wenn `allow_rebuild = true`) + +--- + +## EditorEngine-Interface (CM6 swappable) + +```typescript +// src/components/editor/engine.ts +interface EditorEngine { + getValue(): string + setValue(content: string): void + onSave(callback: (value: string) => void): void + focus(): void + destroy(): void +} +``` + +```typescript +// src/components/editor/cm6-adapter.ts +class CM6Adapter implements EditorEngine { ... } +``` + +```tsx +// src/components/editor/editor-wrapper.tsx +// Kennt nur EditorEngine, nie direkt CM6 +``` + +Wenn CM6 irgendwann getauscht wird → nur `cm6-adapter.ts` anfassen. + +--- + +## Projektstruktur + +``` +noz-studio/ + src/ + app/ + page.tsx # → redirect /editor oder /login + login/ + page.tsx # Login-Screen (token | authentik | both — je nach config) + editor/ + page.tsx # Haupt-Editor: Sidebar + Editor + Preview + components/ + editor/ + engine.ts # EditorEngine Interface + cm6-adapter.ts # CM6 implementiert EditorEngine + editor-wrapper.tsx # React Component — bindet Adapter ein + toolbar.tsx # Formatting-Toolbar (Bold, Italic, Link, Bild...) + status-bar.tsx # Wortanzahl, Status-Badge, Draft-Button, Sprache + sidebar/ + folder-tree.tsx # Topic/Folder Navigation (wie noz Folder-View) + note-list.tsx # Notes im aktuellen Ordner + draft-badge.tsx # Visueller Draft-Indikator + preview/ + preview.tsx # Live-Preview (KaTeX, Wikilinks, Bilder, Tabellen) + auth/ + token-form.tsx # Token-Login Formular + authentik-button.tsx # Authentik SSO Button + lib/ + api.ts # Alle API-Calls zu noz (einzige Stelle die fetch aufruft) + auth.ts # Token lesen/schreiben (sessionStorage) + config.ts # Studio-Config vom Server cachen + hooks/ + use-notes.ts # Notes laden, CRUD + use-editor.ts # Editor-State, Auto-Save + use-config.ts # noosphere.toml Config + public/ + manifest.json # PWA Manifest + icons/ # App-Icons (512x512, 192x192, maskable) + next.config.ts +``` + +--- + +## Features + +### Editor — gebaut ✓ +- [x] Markdown-Editor (CM6, iA Writer-Feel) +- [x] Live-Preview (rechts, togglebar — raw / split / rendered) +- [x] KaTeX Math (`$...$` und `$$...$$`) +- [x] Syntax-Highlighting für Code-Blöcke (highlight.js) +- [x] Formatierungs-Toolbar (Bold/Italic/Strike/H1–H3/Link/Code/Bild/Mathe/Wikilink/…) +- [x] Floating Selection-Toolbar (erscheint bei Textauswahl) +- [x] Einstellungs-Modal (Affine-Stil, vollseitig) +- [x] ⌘K Command Palette (Scoped Search: `@topic`, `@web`, `@draft`) +- [x] Resizable Sidebar (160–360px) + Resizable Preview (200–640px) +- [x] Glassmorphism Navbar (vereint Format-Toolbar + View-Toggle + Suche) + +### Editor — geplant +- [ ] Typora-Style: `**fett**` sofort fett (CM6 Decorations) +- [ ] `/` Slash-Command-Menü (wie Notion/AFFiNE) +- [ ] Wikilink-Autocomplete (`[[` → Vorschläge) +- [ ] Bild-Upload (Drag & Drop → Upload → `![](url)`) + +### Navigation — gebaut ✓ +- [x] Folder-Tree (Topics → Notes, Unterordner-Unterstützung via `subTopics`) +- [x] Draft-Bereich (eigener `_drafts`-Bereich) +- [x] Note umbenennen (inline, im Tree) +- [x] Ordner erstellen (root + Unterordner) +- [x] Ordner umbenennen (inline) +- [x] Note verschieben (Kontextmenü → Ordner-Auswahl) + +### Navigation — geplant +- [ ] Meilisearch-Integration (`/api/search`) +- [ ] Note löschen (mit Bestätigungs-Dialog) + +### Verwaltung — geplant +- [ ] Neue Notiz (Frontmatter-Wizard) +- [ ] 3-Stufen Draft-Button (Draft → In Review → Live) +- [ ] Frontmatter visuell editieren + +### PWA / Mobile — geplant +- [ ] Installierbar (manifest.json) +- [ ] Touch-optimiert (Bottom-Navigation auf Mobile) + +--- + +## Verbindung zu noz + +noz-studio braucht neue API-Routen in noz (noch nicht gebaut — werden nach dem Studio-Design spezifiziert): + +``` +GET /api/studio/config # public — Auth-Methode + Site-Config +GET /api/cli/notes # alle Notes (meta) +GET /api/cli/notes/:slug # Raw Markdown +POST /api/cli/notes # neue Note — { slug, title, topic, status, lang, content } +PUT /api/cli/notes/:slug # Note aktualisieren +DELETE /api/cli/notes/:slug # Note löschen +POST /api/cli/notes/:slug/rename # umbenennen — { newTitle } — Server rewritet Wikilinks +POST /api/cli/notes/:slug/move # Note verschieben — { newTopic } — Server rewritet Wikilinks +GET /api/cli/templates # Templates +POST /api/cli/rebuild # build:index triggern + +POST /api/cli/folders # Neuer Ordner — { slug, parent? } — legt _index.md an +PUT /api/cli/folders/:slug # Ordner umbenennen — { newSlug } — Server rewritet ALLE internen Links +DELETE /api/cli/folders/:slug # Ordner löschen (nur wenn leer — sonst 409) +``` + +Alle außer `/api/studio/config` erfordern `Authorization: Bearer `. + +### Link-Rewriting beim Verschieben/Umbenennen (Serverseite) + +Wenn eine Note verschoben wird (`/move`) oder ein Ordner umbenannt wird (`PUT /folders`): +1. Datei/Verzeichnis physisch verschieben +2. In **allen** `.md`-Dateien des Content-Stores Wikilinks ersetzen: + - Note-Move: `[[alter-topic/note-slug]]` → `[[neuer-topic/note-slug]]` + - Folder-Rename: `[[alter-ordner/...]]` → `[[neuer-ordner/...]]` +3. Index-Rebuild triggern (wenn `allow_rebuild = true`) + +Kurzform: `[[slug]]` ohne Topic-Prefix bleibt unverändert — nur vollqualifizierte Slugs werden rewritten. + +--- + +## Design-System + +Gleiche OKLCH-Palette wie noz (`globals.css`), aber eigene Layout-Sprache: +- **Dark by default** (Editor-Feeling) +- **Newsreader** für Headings (italic), **Inter** für UI, **JetBrains Mono** im Editor +- Kein Prose-Layout — Editor ist das Zentrum + +**Layouts:** +- Desktop: `[Sidebar 260px] [Editor flex] [Preview flex]` +- Mobile: Bottom-Nav + Fullscreen-Editor + Swipe zwischen Editor/Preview diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..4578ca3 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,16 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + headers: async () => [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + ], + }, + ], +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..8cb29d6 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "noz-studio", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3001", + "build": "next build", + "start": "next start --port 3001" + }, + "packageManager": "pnpm@10.0.0", + "dependencies": { + "@codemirror/autocomplete": "^6.20.2", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-markdown": "^6.3.2", + "@codemirror/language": "^6.10.8", + "@codemirror/language-data": "^6.5.2", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.3", + "@lezer/highlight": "^1.2.1", + "highlight.js": "^11.11.1", + "katex": "^0.16.47", + "marked": "^15.0.12", + "next": "16.2.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "smol-toml": "^1.6.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d697160 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1522 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codemirror/autocomplete': + specifier: ^6.20.2 + version: 6.20.2 + '@codemirror/commands': + specifier: ^6.7.1 + version: 6.10.3 + '@codemirror/lang-markdown': + specifier: ^6.3.2 + version: 6.5.0 + '@codemirror/language': + specifier: ^6.10.8 + version: 6.12.3 + '@codemirror/language-data': + specifier: ^6.5.2 + version: 6.5.2 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.36.3 + version: 6.43.0 + '@lezer/highlight': + specifier: ^1.2.1 + version: 1.2.3 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + katex: + specifier: ^0.16.47 + version: 0.16.47 + marked: + specifier: ^15.0.12 + version: 15.0.12 + next: + specifier: 16.2.6 + version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + smol-toml: + specifier: ^1.6.1 + version: 1.6.1 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.3.0 + '@types/node': + specifier: ^20 + version: 20.19.41 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + tailwindcss: + specifier: ^4 + version: 4.3.0 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@codemirror/autocomplete@6.20.2': + resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} + + '@codemirror/commands@6.10.3': + resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + + '@codemirror/lang-cpp@6.0.3': + resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-go@6.0.1': + resolution: {integrity: sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-java@6.0.2': + resolution: {integrity: sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-jinja@6.0.1': + resolution: {integrity: sha512-P5kyHLObzjtbGj16h+hyvZTxJhSjBEeSx4wMjbnAf3b0uwTy2+F0zGjMZL4PQOm/mh2eGZ5xUDVZXgwP783Nsw==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.2': + resolution: {integrity: sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + + '@codemirror/lang-python@6.2.1': + resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + + '@codemirror/lang-rust@6.0.2': + resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + + '@codemirror/lang-yaml@6.1.3': + resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==} + + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/legacy-modes@6.5.3': + resolution: {integrity: sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==} + + '@codemirror/lint@6.9.6': + resolution: {integrity: sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/view@6.43.0': + resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/cpp@1.1.5': + resolution: {integrity: sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==} + + '@lezer/css@1.3.3': + resolution: {integrity: sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==} + + '@lezer/go@1.0.1': + resolution: {integrity: sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/java@1.1.3': + resolution: {integrity: sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.6.3': + resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + + '@lezer/python@1.1.18': + resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} + + '@lezer/rust@1.0.2': + resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.3.0': + resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + baseline-browser-mapping@2.10.30: + resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==} + engines: {node: '>=6.0.0'} + hasBin: true + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.21.3: + resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} + engines: {node: '>=10.13.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@codemirror/autocomplete@6.20.2': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.3': + dependencies: + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-cpp@6.0.3': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/cpp': 1.1.5 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + + '@codemirror/lang-go@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/go': 1.0.1 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.3 + '@lezer/html': 1.3.13 + + '@codemirror/lang-java@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/java': 1.1.3 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/lint': 6.9.6 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-jinja@6.0.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-liquid@6.3.2': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.6.3 + + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/php': 1.0.5 + + '@codemirror/lang-python@6.2.1': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/python': 1.1.18 + + '@codemirror/lang-rust@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/rust': 1.0.2 + + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/xml': 1.0.6 + + '@codemirror/lang-yaml@6.1.3': + dependencies: + '@codemirror/autocomplete': 6.20.2 + '@codemirror/language': 6.12.3 + '@codemirror/state': 6.6.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@lezer/yaml': 1.0.4 + + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/lang-jinja': 6.0.1 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/language': 6.12.3 + '@codemirror/legacy-modes': 6.5.3 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/legacy-modes@6.5.3': + dependencies: + '@codemirror/language': 6.12.3 + + '@codemirror/lint@6.9.6': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.43.0 + crelt: 1.0.6 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.43.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.2': {} + + '@lezer/cpp@1.1.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/css@1.3.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/go@1.0.1': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/java@1.1.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.6.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/python@1.1.18': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/rust@1.0.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@marijn/find-cluster-break@1.0.2': {} + + '@next/env@16.2.6': {} + + '@next/swc-darwin-arm64@16.2.6': + optional: true + + '@next/swc-darwin-x64@16.2.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.6': + optional: true + + '@next/swc-linux-arm64-musl@16.2.6': + optional: true + + '@next/swc-linux-x64-gnu@16.2.6': + optional: true + + '@next/swc-linux-x64-musl@16.2.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.6': + optional: true + + '@next/swc-win32-x64-msvc@16.2.6': + optional: true + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/postcss@4.3.0': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + postcss: 8.5.14 + tailwindcss: 4.3.0 + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + baseline-browser-mapping@2.10.30: {} + + caniuse-lite@1.0.30001793: {} + + client-only@0.0.1: {} + + commander@8.3.0: {} + + crelt@1.0.6: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.21.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + graceful-fs@4.2.11: {} + + highlight.js@11.11.1: {} + + jiti@2.7.0: {} + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@15.0.12: {} + + nanoid@3.3.12: {} + + next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.30 + caniuse-lite: 1.0.30001793 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + picocolors@1.1.1: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react@19.2.4: {} + + scheduler@0.27.0: {} + + semver@7.8.0: + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.0 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + smol-toml@1.6.1: {} + + source-map-js@1.2.1: {} + + style-mod@4.1.3: {} + + styled-jsx@5.1.6(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + w3c-keyname@2.2.8: {} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..7059fe9 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; +export default config; diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..6852ac9 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "noz studio", + "short_name": "noz studio", + "description": "Editor für deinen digitalen Garten", + "start_url": "/editor", + "display": "standalone", + "background_color": "#0a0d18", + "theme_color": "#0a0d18", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx new file mode 100644 index 0000000..c379447 --- /dev/null +++ b/src/app/editor/page.tsx @@ -0,0 +1,665 @@ +"use client" + +import { useState, useCallback, useRef, useEffect, useMemo } from "react" +import Link from "next/link" +import FolderTree from "@/components/sidebar/folder-tree" +import EditorWrapper from "@/components/editor/editor-wrapper" +import FormatButtons from "@/components/editor/toolbar" +import StatusBar from "@/components/editor/status-bar" +import Preview from "@/components/preview/preview" +import FloatingToolbar from "@/components/editor/floating-toolbar" +import SettingsModal from "@/components/editor/settings-modal" +import CommandPalette from "@/components/search/command-palette" +import ThemeToggle from "@/components/theme-toggle" +import { useNotes } from "@/hooks/use-notes" +import { useLocale } from "@/hooks/use-locale" +import { isAuthenticated } from "@/lib/auth" +import FrontmatterPanel from "@/components/editor/frontmatter-panel" +import DiagramCanvas from "@/components/diagram/canvas" +import DiagramTomlEditor from "@/components/diagram/toml-editor" +import { moveNoteApi, createFolder, renameFolder, deleteFolder, getDiagramData, putDiagramToml, NozApiError } from "@/lib/api" +import type { DiagramData } from "@/lib/api" +import { splitContent, buildMarkdown } from "@/lib/frontmatter" +import type { FrontmatterFields } from "@/lib/frontmatter" +import type { EditorEngine, SelectionCoords, NoteRef } from "@/components/editor/engine" +import type { ViewMode } from "@/components/editor/toolbar" + +function countWords(text: string): number { + return text.trim().split(/\s+/).filter(Boolean).length +} + +const DEFAULT_SIDEBAR_W = 220 +const MIN_SIDEBAR_W = 160 +const MAX_SIDEBAR_W = 360 +const DEFAULT_PREVIEW_W = 320 +const MIN_PREVIEW_W = 200 +const MAX_PREVIEW_W = 640 + +export default function EditorPage() { + const { topics, drafts, loading, error, activeNote, noteLoading, + isOnline, hasPending, + selectNote, saveNote, createNote, deleteNote, + renameNote, toggleDraft, updateActiveNote, refreshNotes } = useNotes() + const { locale, setLocale, t } = useLocale() + // content = nur der Body (ohne Frontmatter-Block) + const [content, setContent] = useState("") + const frontmatterRef = useRef("") // aktueller Frontmatter-String + const [frontmatterFields, setFrontmatterFields] = useState(null) + const [viewMode, setViewMode] = useState("split") + const [sidebarOpen, setSidebarOpen] = useState(true) + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_W) + const [isSidebarResizing, setIsSRe] = useState(false) + const [saved, setSaved] = useState(true) + const [selectionCoords, setSelectionCoords] = useState(null) + const [previewWidth, setPreviewWidth] = useState(DEFAULT_PREVIEW_W) + const [isPreviewResizing, setIsPRe] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [deleteSlug, setDeleteSlug] = useState(null) + // Diagram-State + const [diagram, setDiagram] = useState<(DiagramData & { folderSlug: string; mode: "raw" | "rendered" }) | null>(null) + const [diagramToml, setDiagramToml] = useState("") + const engineRef = useRef(null) + const saveTimer = useRef | undefined>(undefined) + + /* ── Auth guard ──────────────────────────────── */ + useEffect(() => { + if (!isAuthenticated()) window.location.href = "/login" + }, []) + + /* ── Note-Graph an Engine weitergeben (für [[ Autocomplete) ── */ + useEffect(() => { + const allNotes: NoteRef[] = [ + ...topics.flatMap(function flat(t): NoteRef[] { + return [...t.notes, ...(t.subTopics?.flatMap(flat) ?? [])] + }), + ...drafts, + ] + engineRef.current?.updateNotes?.(allNotes) + }, [topics, drafts]) + + /* ── activeBody / activeFields: direkt aus activeNote berechnet, nicht durch + content-State — wird als initialContent für EditorWrapper genutzt damit kein + Timing-Gap zwischen Slug-Wechsel und State-Update entsteht. ── */ + const { activeBody, activeFields } = useMemo<{ activeBody: string; activeFields: FrontmatterFields | null }>(() => { + if (!activeNote) return { activeBody: "", activeFields: null } + const { fields, body } = splitContent(activeNote.content) + return { activeBody: body, activeFields: fields } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeNote?.slug]) + + /* ── Sync state when note changes ── */ + useEffect(() => { + if (!activeNote || !activeFields) return + clearTimeout(saveTimer.current) + if (viewMode === "raw") { + frontmatterRef.current = "" + setFrontmatterFields(null) + setContent(activeNote.content) + } else { + frontmatterRef.current = buildMarkdown(activeFields, "") + setFrontmatterFields(activeFields) + setContent(activeBody) + } + setSaved(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeNote?.slug]) + + /* ── Sync editor content when view mode changes ── */ + useEffect(() => { + const engine = engineRef.current + if (!engine || !activeNote) return + if (viewMode === "raw") { + const full = frontmatterRef.current + content + frontmatterRef.current = "" + setContent(full) + engine.setValue(full) + } else { + const { fields, body } = splitContent(content) + frontmatterRef.current = buildMarkdown(fields, "") + setFrontmatterFields(fields ?? activeFields) + setContent(body) + engine.setValue(body) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewMode]) + + // When queue is flushed after coming back online → mark active note as saved + useEffect(() => { + if (!hasPending) setSaved(true) + }, [hasPending]) + + /* ── Keyboard shortcuts ───────────────────────── */ + useEffect(() => { + const h = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setSearchOpen(true) } + if ((e.metaKey || e.ctrlKey) && e.key === ",") { e.preventDefault(); setSettingsOpen(true) } + } + document.addEventListener("keydown", h) + return () => document.removeEventListener("keydown", h) + }, []) + + /* ── Handlers ─────────────────────────────────── */ + const handleChange = useCallback((val: string) => { + setContent(val) + setSaved(false) + clearTimeout(saveTimer.current) + // Snapshot slug and frontmatter at call time — the ref may be overwritten + // if the user switches notes before the 500ms timer fires. + const slug = activeNote?.slug + const fm = frontmatterRef.current + saveTimer.current = setTimeout(() => { + if (!slug) return + saveNote(slug, fm + val) + .then(result => { if (result === "saved") setSaved(true) }) + .catch(console.error) + }, 500) + }, [activeNote?.slug, saveNote]) + + const handleSave = useCallback((val: string) => { + if (!activeNote) return + clearTimeout(saveTimer.current) + const slug = activeNote.slug + const fm = frontmatterRef.current + saveNote(slug, fm + val) + .then(result => { if (result === "saved") setSaved(true) }) + .catch(console.error) + }, [activeNote?.slug, saveNote]) + + const handleSelectNote = useCallback((slug: string) => { + setDiagram(null) + selectNote(slug) + setSelectionCoords(null) + }, [selectNote]) + + const handleNewNote = useCallback((_topic: string | undefined, title: string) => { + if (!title.trim()) return + createNote(title.trim()).catch(console.error) + }, [createNote]) + + const handleDeleteNote = useCallback((slug: string) => setDeleteSlug(slug), []) + const confirmDelete = useCallback(() => { + if (!deleteSlug) return + deleteNote(deleteSlug).catch(console.error) + setDeleteSlug(null) + }, [deleteNote, deleteSlug]) + + const handleRenameNote = useCallback((slug: string, title: string) => { + renameNote(slug, title).catch(console.error) + }, [renameNote]) + + const handleDraftToggle = useCallback(() => { + if (!activeNote) return + toggleDraft(activeNote.slug).catch(console.error) + }, [activeNote?.slug, toggleDraft]) + + const handleFrontmatterChange = useCallback((fields: FrontmatterFields) => { + if (!activeNote) return + frontmatterRef.current = buildMarkdown(fields, "") + setFrontmatterFields(fields) + updateActiveNote(activeNote.slug, { + title: fields.title, + status: fields.status, + lang: fields.lang, + description: fields.description, + }) + saveNote(activeNote.slug, frontmatterRef.current + content).catch(console.error) + }, [activeNote?.slug, content, saveNote, updateActiveNote]) + + const handleSelectDiagram = useCallback((folderSlug: string) => { + getDiagramData(folderSlug) + .then(data => { + setDiagram({ ...data, folderSlug, mode: "rendered" }) + setDiagramToml(data.toml) + }) + .catch((err: unknown) => { + if (err instanceof NozApiError && err.status === 404) { + // Noch kein _diagram.toml vorhanden — leeren Zustand anzeigen + setDiagram({ toml: "", nodes: [], edges: [], direction: "LR", folderSlug, mode: "raw" }) + setDiagramToml("") + } else { + console.error(err) + } + }) + }, []) + + const handleSaveDiagramToml = useCallback(() => { + if (!diagram) return + putDiagramToml(diagram.folderSlug, diagramToml) + .then(() => getDiagramData(diagram.folderSlug)) + .then(data => setDiagram(d => d ? { ...d, ...data } : null)) + .catch(console.error) + }, [diagram, diagramToml]) + + const DEV = process.env.NEXT_PUBLIC_DEV_FIXTURES === "true" + + const handleMoveNote = useCallback((slug: string, topic: string) => { + if (DEV) return + moveNoteApi(slug, topic) + .then(({ newSlug }) => { + refreshNotes() + if (activeNote?.slug === slug) selectNote(newSlug) + }) + .catch(console.error) + }, [DEV, activeNote?.slug, selectNote, refreshNotes]) + + const handleNewFolder = useCallback((parent: string | null, name: string) => { + if (!name.trim() || DEV) return + const slug = (parent ? `${parent}/` : "") + name.trim().toLowerCase().replace(/\s+/g, "-") + createFolder(slug, name.trim()).then(refreshNotes).catch(console.error) + }, [DEV, refreshNotes]) + + const handleRenameFolder = useCallback((slug: string, newSlug: string) => { + if (DEV) return + renameFolder(slug, newSlug).then(refreshNotes).catch(console.error) + }, [DEV, refreshNotes]) + + const handleDeleteFolder = useCallback((slug: string) => { + if (DEV) return + deleteFolder(slug).then(refreshNotes).catch(console.error) + }, [DEV, refreshNotes]) + + /* ── Generic resize factory ───────────────────── */ + function makeResizeHandler( + getStart: (e: React.MouseEvent | React.TouchEvent) => number, + getWidth: (start: number, current: number) => number, + setResizing: (v: boolean) => void, + setWidth: (w: number) => void, + ) { + return (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + setResizing(true) + const start = getStart(e) + const onMove = (ev: MouseEvent | TouchEvent) => { + const x = "touches" in ev ? ev.touches[0].clientX : ev.clientX + setWidth(getWidth(start, x)) + } + const onEnd = () => { + setResizing(false) + document.removeEventListener("mousemove", onMove) + document.removeEventListener("mouseup", onEnd) + document.removeEventListener("touchmove", onMove) + document.removeEventListener("touchend", onEnd) + } + document.addEventListener("mousemove", onMove) + document.addEventListener("mouseup", onEnd) + document.addEventListener("touchmove", onMove, { passive: false }) + document.addEventListener("touchend", onEnd) + } + } + + const handleSidebarResizeStart = useCallback( + makeResizeHandler( + (e) => "touches" in e ? e.touches[0].clientX : e.clientX, + (start, x) => Math.max(MIN_SIDEBAR_W, Math.min(MAX_SIDEBAR_W, sidebarWidth + (x - start))), + setIsSRe, + setSidebarWidth, + ), + [sidebarWidth] + ) + + const handlePreviewResizeStart = useCallback( + makeResizeHandler( + (e) => "touches" in e ? e.touches[0].clientX : e.clientX, + (start, x) => Math.max(MIN_PREVIEW_W, Math.min(MAX_PREVIEW_W, previewWidth + (start - x))), + setIsPRe, + setPreviewWidth, + ), + [previewWidth] + ) + + /* ── Render ───────────────────────────────────── */ + const fullContent = frontmatterRef.current + content + + return ( +
+ {/* Overlays */} + + setSettingsOpen(false)} /> + + {/* ── Lösch-Bestätigung ─────────────────────── */} + {deleteSlug && ( +
{ if (e.target === e.currentTarget) setDeleteSlug(null) }} + > +
+
+

{t.deleteNote}

+

{deleteSlug}

+
+

{t.deleteWarning}

+
+ + +
+
+
+ )} + setSearchOpen(false)} + onSelectNote={handleSelectNote} + notes={topics.flatMap(t => t.notes)} drafts={drafts} topics={topics} + /> + + {/* ── Floating navbar ───────────────────────── */} +
+
+ setSidebarOpen((v) => !v)} title={t.sidebar}> + + + + noz studio + + +
+ + + {activeNote?.slug ?? "…"} + + + {viewMode !== "rendered" && ( + <> +
+ + + )} + +
+ + {/* View mode switcher */} +
+ setViewMode("raw")} title={t.editorOnly}> +
+ setViewMode("split")} title={t.split}> +
+ setViewMode("rendered")} title={t.previewOnly}> +
+ +
+ + setSearchOpen(true)} title={`${t.search} (⌘K)`}> + + + setSettingsOpen(true)} title={`${t.settings} (⌘,)`}> +
+
+ + {/* ── Loading / Error overlay ───────────────── */} + {loading && ( +
+ {t.loadingNotes} +
+ )} + {error && ( +
+
+ {error} +
+
+ )} + + {/* ── Main ──────────────────────────────────── */} +
+ + {/* Sidebar + drag handle */} + {sidebarOpen && ( + <> + + + {/* Sidebar resize handle */} +
+
+
+
+ {[0,1,2].map((i) =>
)} +
+
+ + )} + + {/* Editor + Preview */} +
+ + {/* Frontmatter Panel (nur im Split/Rendered-Modus — Raw zeigt das --- Block inline) */} + {frontmatterFields && !diagram && viewMode !== "raw" && ( + + )} + +
+ + {noteLoading && ( +
+ {t.loadingNote} +
+ )} + + {/* Diagram-Ansicht */} + {!noteLoading && diagram && ( +
+
+ + {diagram.folderSlug} + +
+ setDiagram(d => d ? { ...d, mode: "rendered" } : d)} title="Gerendert"> + + +
+ setDiagram(d => d ? { ...d, mode: "raw" } : d)} title="TOML"> + + +
+ +
+ + {diagram.mode === "rendered" && ( +
+ {diagram.nodes.length === 0 ? ( +
+ +

+ Noch kein Diagramm vorhanden. +
+ Wechsle zu TOML und definiere Knoten. +

+ +
+ ) : ( + + )} +
+ )} + + {diagram.mode === "raw" && ( + + )} +
+ )} + + {!noteLoading && !diagram && viewMode !== "rendered" && ( + { engineRef.current = e }} + onSelectionChange={setSelectionCoords} + className="flex-1 overflow-hidden min-h-0" + /> + )} + + {!diagram && viewMode === "split" && ( + <> + {/* Preview resize handle */} +
+
+
+
+ {[0,1,2].map((i) =>
)} +
+
+ +
+
+ Preview +
+ +
+ + )} + + {!diagram && viewMode === "rendered" && ( + + )} +
+ + {activeNote && !diagram && ( + + )} +
+
+
+ ) +} + +/* ── Header helpers ─────────────────────────────── */ +function HeaderBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) { + return ( + + ) +} +function ViewBtn({ onClick, title, active, children }: { onClick: () => void; title?: string; active: boolean; children: React.ReactNode }) { + return ( + + ) +} + +/* ── Icons ──────────────────────────────────────── */ +function OrbitalIcon({ className }: { className?: string }) { + return +} +function SidebarIcon() { return } +function SearchIcon() { return } +function SettingsIcon() { return } +function PenIcon() { return } +function SplitIcon() { return } +function EyeIcon() { return } diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..e6fe8ad --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,306 @@ +@import "tailwindcss"; + +@variant dark (&:where(.dark, .dark *)); + +@theme inline { + --font-sans: var(--font-inter); + --font-mono: var(--font-jetbrains); + --font-serif: var(--font-newsreader); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-fg: var(--muted-fg); + --color-border: var(--border); + --color-accent: var(--accent); + --color-accent-fg: var(--accent-fg); + --color-surface: var(--surface); + --color-surface-2: var(--surface-2); + + --color-seed: var(--seed); + --color-budding: var(--budding); + --color-evergreen: var(--evergreen); + --color-draft: var(--draft); +} + +/* ── Light — warm stone ─────────────────────────── */ +:root { + color-scheme: light; + + --background: oklch(98.5% 0.002 75); + --foreground: oklch(14.5% 0.013 58); + --muted: oklch(96% 0.003 75); + --muted-fg: oklch(52% 0.022 58); + --border: oklch(91% 0.006 75); + --accent: oklch(38% 0.16 253); + --accent-fg: oklch(98.5% 0.002 75); + --surface: oklch(93.5% 0.005 75); + --surface-2: oklch(89% 0.008 75); + + --seed: oklch(62% 0.2 75); + --budding: oklch(52% 0.22 290); + --evergreen: oklch(48% 0.2 220); + --draft: oklch(56% 0.18 30); + + /* Syntax highlight — light */ + --sh-heading: oklch(12% 0.015 58); + --sh-code: oklch(44% 0.2 220); + --sh-link: oklch(40% 0.18 253); + --sh-punct: oklch(68% 0.01 75); + --sh-quote: oklch(56% 0.018 58); + --sh-math: oklch(42% 0.22 290); + --sh-strike: oklch(72% 0.01 75); +} + +/* ── Dark — deep space ──────────────────────────── */ +.dark { + color-scheme: dark; + + --background: oklch(8% 0.05 272); + --foreground: oklch(87% 0.055 258); + --muted: oklch(10.5% 0.065 272); + --muted-fg: oklch(62% 0.065 263); + --border: oklch(16% 0.09 272); + --accent: oklch(77% 0.13 253); + --accent-fg: oklch(8% 0.05 272); + --surface: oklch(13% 0.07 272); + --surface-2: oklch(18% 0.08 272); + + --seed: oklch(78% 0.18 75); + --budding: oklch(72% 0.2 290); + --evergreen: oklch(75% 0.18 220); + --draft: oklch(70% 0.15 30); + + /* Syntax highlight — dark */ + --sh-heading: oklch(93% 0.04 258); + --sh-code: oklch(75% 0.18 220); + --sh-link: oklch(72% 0.17 220); + --sh-punct: oklch(40% 0.06 272); + --sh-quote: oklch(62% 0.065 263); + --sh-math: oklch(72% 0.2 290); + --sh-strike: oklch(48% 0.04 272); +} + +*, +*::before, +*::after { + border-color: var(--border); + box-sizing: border-box; +} + +body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +/* Nebula glow — dark only */ +.dark body::before { + content: ""; + position: fixed; + inset: 0; + background: radial-gradient( + ellipse 80% 35% at 50% -5%, + oklch(20% 0.1 272 / 0.18), + transparent + ); + pointer-events: none; + z-index: 0; +} + +/* ── Scrollbar ────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: oklch(from var(--border) l c h / 0.8); + border-radius: 9999px; +} +::-webkit-scrollbar-thumb:hover { background: var(--border); } + +::selection { + background: oklch(from var(--accent) l c h / 0.2); +} + +/* ── Preview prose ────────────────────────────────── */ +.preview-prose { + font-family: var(--font-serif); + font-size: 0.9375rem; + line-height: 1.85; + color: var(--foreground); +} + +.preview-prose h1, +.preview-prose h2, +.preview-prose h3, +.preview-prose h4 { + font-style: italic; + font-weight: 400; + letter-spacing: -0.02em; + line-height: 1.25; + margin-top: 1.75em; + margin-bottom: 0.5em; + color: var(--sh-heading); +} +.preview-prose h1 { font-size: 1.6em; margin-top: 0; } +.preview-prose h2 { font-size: 1.3em; } +.preview-prose h3 { font-size: 1.1em; } + +.preview-prose p { margin-bottom: 1em; } +.preview-prose strong { font-weight: 700; } +.preview-prose em { font-style: italic; } + +.preview-prose code { + font-family: var(--font-mono); + font-size: 0.82em; + color: var(--sh-code); + background: var(--surface); + padding: 0.15em 0.35em; + border-radius: 4px; + border: 1px solid var(--border); +} + +.preview-prose pre { + background: var(--muted); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin: 1.25em 0; +} +.preview-prose pre code { + background: transparent; + border: none; + padding: 0; + font-size: 0.85em; + color: var(--foreground); +} + +.preview-prose a { + color: var(--sh-link); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: oklch(from var(--sh-link) l c h / 0.4); +} +.preview-prose a:hover { + text-decoration-color: var(--sh-link); +} +.preview-prose .preview-wikilink { + color: var(--sh-link); + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-style: dotted; + text-decoration-color: oklch(from var(--sh-link) l c h / 0.5); + cursor: pointer; +} +.preview-prose .preview-wikilink:hover { + text-decoration-color: var(--sh-link); +} +.preview-prose .preview-wikilink[data-broken="true"] { + color: var(--draft); + text-decoration-color: oklch(from var(--draft) l c h / 0.5); +} +.preview-prose .preview-wikilink[data-broken="true"]:hover { + text-decoration-color: var(--draft); +} + +/* ── Preview toast ──────────────────────────────────── */ +.preview-toast { + position: sticky; + top: 0.75rem; + left: 50%; + transform: translateX(-50%); + width: fit-content; + max-width: calc(100% - 3rem); + z-index: 50; + padding: 0.45rem 0.85rem; + border-radius: 8px; + border: 1px solid oklch(from var(--draft) l c h / 0.35); + background: oklch(from var(--draft) l c h / 0.1); + color: var(--draft); + font-family: var(--font-mono); + font-size: 0.75rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.preview-prose ul, .preview-prose ol { padding-left: 1.5em; margin-bottom: 1em; } +.preview-prose ul { list-style-type: disc; } +.preview-prose ul ul { list-style-type: circle; } +.preview-prose ul ul ul { list-style-type: square; } +.preview-prose ol { list-style-type: decimal; } +.preview-prose ol ol { list-style-type: lower-alpha; } +.preview-prose li { margin-bottom: 0.3em; } +.preview-prose li > ul, .preview-prose li > ol { margin-top: 0.2em; margin-bottom: 0; } + +.preview-prose blockquote { + border-left: 2px solid var(--border); + padding-left: 1.25em; + color: var(--sh-quote); + font-style: italic; + margin: 1.25em 0; +} + +.preview-prose table { + width: 100%; + border-collapse: collapse; + font-size: 0.875em; + margin: 1.25em 0; +} +.preview-prose th, .preview-prose td { + padding: 0.5em 0.75em; + border: 1px solid var(--border); + text-align: left; +} +.preview-prose th { + background: var(--muted); + font-weight: 600; + font-style: normal; +} + +.preview-prose hr { + border: none; + border-top: 1px solid var(--border); + margin: 2em 0; +} + +/* ── highlight.js — adapts to light + dark via CSS vars ── */ +.hljs { background: transparent; color: var(--foreground); } +.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--sh-math); font-weight: 500; } +.hljs-string, .hljs-attr, .hljs-attribute { color: var(--sh-link); } +.hljs-comment, .hljs-quote { color: var(--sh-quote); font-style: italic; } +.hljs-number, .hljs-literal, .hljs-symbol { color: var(--sh-code); } +.hljs-class .hljs-title, .hljs-type { color: var(--sh-heading); } +.hljs-function .hljs-title, .hljs-title { color: var(--sh-code); } +.hljs-operator, .hljs-punctuation { color: var(--sh-punct); } +.hljs-variable, .hljs-template-variable { color: var(--foreground); } +.hljs-meta { color: var(--sh-punct); } +.hljs-tag { color: var(--sh-math); } +.hljs-name { color: var(--sh-code); } +.hljs-deletion { background: oklch(from var(--draft) l c h / 0.15); } +.hljs-addition { background: oklch(from var(--evergreen) l c h / 0.12); } + +/* ── Math blocks ─────────────────────────────────────── */ +.math-block { + overflow-x: auto; + padding: 1rem 0; + text-align: center; +} +.math-error { color: var(--draft); font-family: var(--font-mono); font-size: 0.85em; } + +/* ── Landing page animation ──────────────────────────── */ +@keyframes star-pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +@keyframes orbital-breathe { + 0%, 100% { opacity: 0.85; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.04); } +} +@keyframes orbital-electron { + from { transform: rotate(0deg) translateX(19px) rotate(0deg); } + to { transform: rotate(360deg) translateX(19px) rotate(-360deg); } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..f91f26d --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next" +import { Inter, Newsreader, JetBrains_Mono } from "next/font/google" +import Script from "next/script" +import "./globals.css" +import "katex/dist/katex.min.css" + +const inter = Inter({ variable: "--font-inter", subsets: ["latin"] }) +const newsreader = Newsreader({ + variable: "--font-newsreader", + subsets: ["latin"], + style: ["normal", "italic"], +}) +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains", + subsets: ["latin"], +}) + +export const metadata: Metadata = { + title: "noz studio", + description: "Editor für deinen digitalen Garten", + manifest: "/manifest.json", + appleWebApp: { capable: true, statusBarStyle: "default", title: "noz studio" }, +} + +const themeScript = `(function(){try{var t=localStorage.getItem('noz-studio-theme');if(t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)){document.documentElement.classList.add('dark')}}catch(e){}})()`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..e5629be --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter } from "next/navigation" +import { setCredentials, isAuthenticated } from "@/lib/auth" +import { fetchStudioConfig, testToken, NozApiError } from "@/lib/api" + +export default function LoginPage() { + const router = useRouter() + const devServer = process.env.NEXT_PUBLIC_DEV_SERVER ?? "" + const devToken = process.env.NEXT_PUBLIC_DEV_TOKEN ?? "" + + const [url, setUrl] = useState(devServer || "https://noz.kryptomnrx.de") + const [token, setToken] = useState(devToken) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Fixture-Modus oder bereits angemeldet → direkt zum Editor + useEffect(() => { + if (isAuthenticated()) router.replace("/editor") + }, [router]) + + async function handleConnect(e: React.FormEvent) { + e.preventDefault() + if (!url.trim() || !token.trim()) { + setError("Bitte Server-URL und Token eingeben.") + return + } + + setLoading(true) + setError(null) + + try { + // Schritt 1: Server als noz-Instanz verifizieren + await fetchStudioConfig(url.trim()) + + // Schritt 2: Token validieren + await testToken(url.trim(), token.trim()) + + // Erfolg: Credentials speichern + weiterleiten + setCredentials(url.trim(), token.trim()) + router.push("/editor") + } catch (err) { + if (err instanceof NozApiError) { + setError(err.message) + } else { + setError("Unbekannter Fehler — bitte erneut versuchen.") + } + setLoading(false) + } + } + + return ( +
+ {/* Nebula glows */} +
+
+ + {/* Card */} +
+ {/* Logo */} +
+ +
+ + noz + + + studio + +
+
+ +
+
+
+ + {/* Fehlermeldung */} + {error && ( +
+ + {error} +
+ )} + +
+ + { setUrl(e.target.value); setError(null) }} + placeholder="https://noz.example.com" + disabled={loading} + required + className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-fg/30 focus:outline-none focus:border-accent transition-colors font-mono disabled:opacity-50" + /> +
+ +
+ + { setToken(e.target.value); setError(null) }} + placeholder="••••••••••••••••••••••••" + disabled={loading} + required + className="w-full bg-surface border border-border rounded-lg px-3 py-2 text-sm text-foreground placeholder:text-muted-fg/30 focus:outline-none focus:border-accent transition-colors font-mono disabled:opacity-50" + /> +
+ + +
+ + {/* Divider */} +
+
+ oder +
+
+ + {/* Authentik — noch nicht implementiert */} +
+ +
+
+ + +

+ Token aus{" "} + noosphere.toml [cli] +

+
+
+ ) +} + +/* ── Icons ──────────────────────────────────────────── */ + +function OrbitalIcon({ className }: { className?: string }) { + return ( + + + + + + ) +} + +function AuthentikIcon() { + return ( + + + + + + ) +} + +function ErrorIcon() { + return ( + + + + + + ) +} + +function SpinnerIcon() { + return ( + + + + ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..be8b130 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,184 @@ +import Link from "next/link" +import ThemeToggle from "@/components/theme-toggle" +import StarField from "@/components/star-field" + +export default function LandingPage() { + return ( +
+ {/* Starfield — dark mode only */} +
+ +
+ + {/* Nebula glows */} +
+ {/* Main top glow */} +
+ {/* Secondary glow bottom-right */} +
+ {/* Tertiary glow bottom-left */} +
+
+ + {/* Top bar */} +
+ +
+ + {/* Center content */} +
+ + {/* Orbital logo */} +
+ {/* Glow behind logo */} +
+
+ + + + {/* Second orbit ring, subtle */} + + + + +
+
+ + {/* Title block */} +
+

+ noz studio +

+

+ Schreiben · Denken · Veröffentlichen +

+
+ + {/* Feature chips */} +
+ {["Markdown", "KaTeX", "Wikilinks", "Syntax-Highlighting", "PWA", "Dark Mode"].map((tag) => ( + + {tag} + + ))} +
+ + {/* CTAs */} +
+ + Studio öffnen + + + + Anmelden + +
+ + {/* Hint */} +

+ Verbinde dich mit deiner noz-Instanz und schreibe von überall. +

+
+ + {/* Footer */} +
+ v0.1.0 +
+
+ ) +} + +function ArrowIcon() { + return ( + + + + + ) +} diff --git a/src/components/diagram/canvas.tsx b/src/components/diagram/canvas.tsx new file mode 100644 index 0000000..2ad15b8 --- /dev/null +++ b/src/components/diagram/canvas.tsx @@ -0,0 +1,328 @@ +"use client" + +import { useEffect, useRef, useState, useCallback } from "react" +import type { DiagramNode, DiagramEdge, DiagramDirection, LayoutNode, ThemeColors, Transform } from "./types" +import { computeLayout } from "./layout" +import { renderFrame } from "./render" + +function readColors(): ThemeColors { + const s = getComputedStyle(document.documentElement) + const g = (v: string) => s.getPropertyValue(v).trim() + return { + bg: g("--background"), + fg: g("--foreground"), + muted: g("--muted"), + mutedFg: g("--muted-fg"), + border: g("--border"), + accent: g("--accent"), + shadowHard: g("--shadow-hard"), + } +} + +export default function DiagramCanvas({ + nodes, edges, direction = "LR", onSelectNote, +}: { + nodes: DiagramNode[] + edges: DiagramEdge[] + direction?: DiagramDirection + onSelectNote?: (slug: string) => void +}) { + const canvasRef = useRef(null) + const controlsRef = useRef({ zoomIn: () => {}, zoomOut: () => {}, fit: () => {} }) + const [fullscreen, setFullscreen] = useState(false) + const toggleFullscreen = useCallback(() => setFullscreen(f => !f), []) + + useEffect(() => { + if (!fullscreen) return + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setFullscreen(false) } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [fullscreen]) + + useEffect(() => { + const canvas = canvasRef.current! + const ctx = canvas.getContext("2d")! + + const layoutNodes = computeLayout(nodes, edges, direction) + const nodeMap = new Map(layoutNodes.map(n => [n.id, n])) + let colors = readColors() + let dark = document.documentElement.classList.contains("dark") + + const dpr = devicePixelRatio || 1 + const W = () => canvas.offsetWidth + const H = () => canvas.offsetHeight + + const state = { + transform: { x: 0, y: 0, k: 1 } as Transform, + hovered: null as LayoutNode | null, + dragging: false, + lastMouse: { x: 0, y: 0 }, + } + + function resize() { + canvas.width = canvas.offsetWidth * dpr + canvas.height = canvas.offsetHeight * dpr + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + render() + } + + const ro = new ResizeObserver(resize) + ro.observe(canvas) + resize() + + function fitView() { + if (!layoutNodes.length) return + const xs = layoutNodes.flatMap(n => [n.x, n.x + n.w]) + const ys = layoutNodes.flatMap(n => [n.y, n.y + n.h]) + const minX = Math.min(...xs), maxX = Math.max(...xs) + const minY = Math.min(...ys), maxY = Math.max(...ys) + const pad = 72 + const k = Math.min(2, Math.min( + (W() - pad * 2) / (maxX - minX || 1), + (H() - pad * 2) / (maxY - minY || 1), + )) + state.transform = { + x: W() / 2 - ((minX + maxX) / 2) * k, + y: H() / 2 - ((minY + maxY) / 2) * k, + k, + } + } + + function zoomBy(factor: number) { + const cx = W() / 2, cy = H() / 2 + const newK = Math.max(0.2, Math.min(4, state.transform.k * factor)) + const scale = newK / state.transform.k + state.transform.x = cx + (state.transform.x - cx) * scale + state.transform.y = cy + (state.transform.y - cy) * scale + state.transform.k = newK + schedule() + } + + controlsRef.current = { + zoomIn: () => zoomBy(1.25), + zoomOut: () => zoomBy(0.8), + fit: () => { fitView(); schedule() }, + } + + fitView() + + const mo = new MutationObserver(() => { + colors = readColors() + dark = document.documentElement.classList.contains("dark") + schedule() + }) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }) + + let rafId = 0 + function schedule() { + if (rafId) return + rafId = requestAnimationFrame(() => { rafId = 0; render() }) + } + function render() { + renderFrame({ ctx, w: W(), h: H(), layoutNodes, edges, nodeMap, colors, dark, transform: state.transform, hoveredId: state.hovered?.id ?? null }) + } + schedule() + + function nodeAt(mx: number, my: number): LayoutNode | null { + const { x, y, k } = state.transform + const wx = (mx - x) / k, wy = (my - y) / k + for (const node of layoutNodes) { + if (wx < node.x || wx > node.x + node.w) continue + if (wy < node.y || wy > node.y + node.h) continue + if (node.shape === "circle") { + const cx = node.x + node.w / 2, cy = node.y + node.h / 2 + if (Math.hypot(wx - cx, wy - cy) > node.w / 2) continue + } + if (node.shape === "diamond") { + const cx = node.x + node.w / 2, cy = node.y + node.h / 2 + if (Math.abs((wx - cx) / (node.w / 2)) + Math.abs((wy - cy) / (node.h / 2)) > 1) continue + } + return node + } + return null + } + + let downPos = { x: 0, y: 0 } + + function onMouseMove(e: MouseEvent) { + const rect = canvas.getBoundingClientRect() + const mx = e.clientX - rect.left, my = e.clientY - rect.top + if (state.dragging) { + state.transform.x += mx - state.lastMouse.x + state.transform.y += my - state.lastMouse.y + state.lastMouse = { x: mx, y: my } + schedule(); return + } + const hit = nodeAt(mx, my) + if (hit?.id !== state.hovered?.id) { + state.hovered = hit + canvas.style.cursor = hit?.slug ? "pointer" : hit ? "default" : "grab" + schedule() + } + state.lastMouse = { x: mx, y: my } + } + + function onMouseDown(e: MouseEvent) { + const rect = canvas.getBoundingClientRect() + downPos = { x: e.clientX - rect.left, y: e.clientY - rect.top } + state.dragging = true; state.lastMouse = downPos + canvas.style.cursor = "grabbing" + } + + function onMouseUp(e: MouseEvent) { + const rect = canvas.getBoundingClientRect() + const mx = e.clientX - rect.left, my = e.clientY - rect.top + state.dragging = false + if (Math.abs(mx - downPos.x) < 5 && Math.abs(my - downPos.y) < 5) { + const hit = nodeAt(mx, my) + if (hit?.slug) onSelectNote?.(hit.slug) + } + const hit = nodeAt(mx, my) + canvas.style.cursor = hit?.slug ? "pointer" : hit ? "default" : "grab" + } + + function onWheel(e: WheelEvent) { + e.preventDefault() + const rect = canvas.getBoundingClientRect() + const mx = e.clientX - rect.left, my = e.clientY - rect.top + if (e.ctrlKey) { + const factor = e.deltaY < 0 ? 1.1 : 0.91 + const newK = Math.max(0.2, Math.min(4, state.transform.k * factor)) + const scale = newK / state.transform.k + state.transform.x = mx + (state.transform.x - mx) * scale + state.transform.y = my + (state.transform.y - my) * scale + state.transform.k = newK + } else { + state.transform.x -= e.deltaX + state.transform.y -= e.deltaY + } + schedule() + } + + function onMouseLeave() { + state.dragging = false; state.hovered = null + canvas.style.cursor = "grab"; schedule() + } + + const isTouchDevice = navigator.maxTouchPoints > 0 + const activeTouches = new Map() + const touchStart = new Map() + let lastPinchDist: number | null = null + let lastPinchCenter: { x: number; y: number } | null = null + + function tp(touch: Touch) { + const rect = canvas.getBoundingClientRect() + return { x: touch.clientX - rect.left, y: touch.clientY - rect.top } + } + + function onTouchStart(e: TouchEvent) { + e.preventDefault() + for (const t of e.changedTouches) { const pos = tp(t); activeTouches.set(t.identifier, pos); touchStart.set(t.identifier, { ...pos }) } + if (activeTouches.size === 1) { const [pos] = activeTouches.values(); state.dragging = true; state.lastMouse = pos } + } + + function onTouchMove(e: TouchEvent) { + e.preventDefault() + for (const t of e.changedTouches) activeTouches.set(t.identifier, tp(t)) + if (activeTouches.size >= 2) { + const [a, b] = activeTouches.values() + const dist = Math.hypot(b.x - a.x, b.y - a.y), cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2 + if (lastPinchDist !== null && lastPinchCenter !== null) { + const factor = dist / lastPinchDist + const newK = Math.abs(factor - 1) > 0.02 ? Math.max(0.2, Math.min(4, state.transform.k * factor)) : state.transform.k + const canvasX = (lastPinchCenter.x - state.transform.x) / state.transform.k + const canvasY = (lastPinchCenter.y - state.transform.y) / state.transform.k + state.transform = { x: cx - canvasX * newK, y: cy - canvasY * newK, k: newK } + schedule() + } + lastPinchDist = dist; lastPinchCenter = { x: cx, y: cy }; return + } + lastPinchDist = null; lastPinchCenter = null + if (activeTouches.size === 1 && state.dragging) { + const [pos] = activeTouches.values() + state.transform.x += pos.x - state.lastMouse.x; state.transform.y += pos.y - state.lastMouse.y + state.lastMouse = pos; schedule() + } + } + + function onTouchEnd(e: TouchEvent) { + e.preventDefault() + for (const t of e.changedTouches) { + const start = touchStart.get(t.identifier), end = tp(t) + if (start && activeTouches.size === 1 && Math.abs(end.x - start.x) < 8 && Math.abs(end.y - start.y) < 8) { + const hit = nodeAt(end.x, end.y) + if (hit?.slug) onSelectNote?.(hit.slug) + } + activeTouches.delete(t.identifier); touchStart.delete(t.identifier) + } + lastPinchDist = null; lastPinchCenter = null + if (activeTouches.size === 0) state.dragging = false + } + + canvas.addEventListener("mousemove", onMouseMove) + canvas.addEventListener("mousedown", onMouseDown) + canvas.addEventListener("mouseup", onMouseUp) + canvas.addEventListener("wheel", onWheel, { passive: false }) + canvas.addEventListener("mouseleave", onMouseLeave) + if (isTouchDevice) { + canvas.addEventListener("touchstart", onTouchStart, { passive: false }) + canvas.addEventListener("touchmove", onTouchMove, { passive: false }) + canvas.addEventListener("touchend", onTouchEnd, { passive: false }) + } + canvas.style.cursor = "grab" + + return () => { + cancelAnimationFrame(rafId); ro.disconnect(); mo.disconnect() + canvas.removeEventListener("mousemove", onMouseMove) + canvas.removeEventListener("mousedown", onMouseDown) + canvas.removeEventListener("mouseup", onMouseUp) + canvas.removeEventListener("wheel", onWheel) + canvas.removeEventListener("mouseleave", onMouseLeave) + if (isTouchDevice) { + canvas.removeEventListener("touchstart", onTouchStart) + canvas.removeEventListener("touchmove", onTouchMove) + canvas.removeEventListener("touchend", onTouchEnd) + } + } + }, [nodes, edges, direction, onSelectNote]) + + const containerCls = fullscreen + ? "fixed inset-0 z-50 bg-background" + : "relative w-full h-full" + + return ( +
+ + {/* Zoom-Controls */} +
+ {[ + { title: "Hineinzoomen", fn: () => controlsRef.current.zoomIn(), icon: "M6 2v8M2 6h8" }, + { title: "Herauszoomen", fn: () => controlsRef.current.zoomOut(), icon: "M2 6h8" }, + { title: "Übersicht", fn: () => controlsRef.current.fit(), icon: "M1.5 1.5h3v3h-3zM7.5 1.5h3v3h-3zM1.5 7.5h3v3h-3zM7.5 7.5h3v3h-3z" }, + ].map((btn, i) => ( +
+ {i > 0 &&
} + +
+ ))} +
+ +
+
+ ) +} diff --git a/src/components/diagram/layout.ts b/src/components/diagram/layout.ts new file mode 100644 index 0000000..65dc5b1 --- /dev/null +++ b/src/components/diagram/layout.ts @@ -0,0 +1,101 @@ +import type { DiagramNode, DiagramEdge, DiagramDirection, LayoutNode } from "./types"; + +export const NODE_DIMS: Record = { + rect: { w: 176, h: 72 }, + diamond: { w: 120, h: 120 }, + circle: { w: 100, h: 100 }, + pill: { w: 196, h: 46 }, +}; + +const LAYER_GAP = 260; +const ROW_GAP = 100; + +export function computeLayout( + nodes: DiagramNode[], + edges: DiagramEdge[], + direction: DiagramDirection = "LR", +): LayoutNode[] { + if (nodes.length === 0) return []; + + const ids = nodes.map(n => n.id); + const nodeMap = new Map(nodes.map(n => [n.id, n])); + + // Build directed adjacency + in-degree (forward edges only for layout) + const children = new Map(ids.map(id => [id, []])); + const inDegree = new Map(ids.map(id => [id, 0])); + + for (const e of edges) { + if (!nodeMap.has(e.from) || !nodeMap.has(e.to)) continue; + children.get(e.from)!.push(e.to); + inDegree.set(e.to, (inDegree.get(e.to) ?? 0) + 1); + } + + // Kahn's topological sort → process in topo order + const inDegClone = new Map(inDegree); + const queue = ids.filter(id => (inDegClone.get(id) ?? 0) === 0); + const topo: string[] = []; + + while (queue.length > 0) { + const id = queue.shift()!; + topo.push(id); + for (const child of (children.get(id) ?? [])) { + const d = (inDegClone.get(child) ?? 1) - 1; + inDegClone.set(child, d); + if (d === 0) queue.push(child); + } + } + + // Assign layer = longest path depth from any source + const layer = new Map(ids.map(id => [id, 0])); + for (const id of topo) { + for (const child of (children.get(id) ?? [])) { + const candidate = (layer.get(id) ?? 0) + 1; + if (candidate > (layer.get(child) ?? 0)) layer.set(child, candidate); + } + } + + // Disconnected / cyclic nodes fall back to layer 0 + for (const id of ids) { + if (!topo.includes(id)) layer.set(id, 0); + } + + // Group by layer, preserving original order within each layer + const layerGroups = new Map(); + for (const id of ids) { + const l = layer.get(id) ?? 0; + if (!layerGroups.has(l)) layerGroups.set(l, []); + layerGroups.get(l)!.push(id); + } + + const result: LayoutNode[] = []; + + for (const [l, group] of Array.from(layerGroups.entries()).sort((a, b) => a[0] - b[0])) { + // Total size of this layer (perpendicular axis) — centered around 0 + const totalPerp = group.reduce((acc, id, i) => { + const dims = NODE_DIMS[nodeMap.get(id)?.shape ?? "rect"] ?? NODE_DIMS.rect; + const size = direction === "TB" ? dims.w : dims.h; + return acc + size + (i > 0 ? ROW_GAP : 0); + }, 0); + + let pos = -totalPerp / 2; + for (const id of group) { + const node = nodeMap.get(id)!; + const dims = NODE_DIMS[node.shape] ?? NODE_DIMS.rect; + + const main = l * LAYER_GAP; // position along flow axis + const perp = pos + (direction === "TB" ? dims.w : dims.h) / 2; // center on perp axis + + result.push({ + ...node, + x: direction === "TB" ? perp - dims.w / 2 : main - dims.w / 2, + y: direction === "TB" ? main - dims.h / 2 : pos, + w: dims.w, + h: dims.h, + }); + + pos += (direction === "TB" ? dims.w : dims.h) + ROW_GAP; + } + } + + return result; +} diff --git a/src/components/diagram/render.ts b/src/components/diagram/render.ts new file mode 100644 index 0000000..b4865eb --- /dev/null +++ b/src/components/diagram/render.ts @@ -0,0 +1,375 @@ +import type { LayoutNode, DiagramEdge, ThemeColors, Transform } from "./types"; + +const STATUS_COLOR: Record = { + seed: "oklch(75% 0.18 70)", // amber + budding: "oklch(65% 0.2 295)", // violet + evergreen: "oklch(68% 0.18 220)", // sky +}; + +function resolveNodeColor(color: string | undefined, colors: ThemeColors): string { + if (!color) return colors.muted; + return STATUS_COLOR[color] ?? color; +} + +// ── Shape primitives ────────────────────────────────────────────────────────── + +function rrect( + ctx: CanvasRenderingContext2D, + x: number, y: number, w: number, h: number, r: number, +) { + const R = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + R, y); + ctx.lineTo(x + w - R, y); + ctx.arcTo(x + w, y, x + w, y + R, R); + ctx.lineTo(x + w, y + h - R); + ctx.arcTo(x + w, y + h, x + w - R, y + h, R); + ctx.lineTo(x + R, y + h); + ctx.arcTo(x, y + h, x, y + h - R, R); + ctx.lineTo(x, y + R); + ctx.arcTo(x, y, x + R, y, R); + ctx.closePath(); +} + +// Returns the point on the node boundary in direction `angle` from its center +function boundaryPoint(node: LayoutNode, angle: number): { x: number; y: number } { + const cx = node.x + node.w / 2; + const cy = node.y + node.h / 2; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + + if (node.shape === "circle") { + const r = node.w / 2; + return { x: cx + cos * r, y: cy + sin * r }; + } + + if (node.shape === "diamond") { + // Diamond boundary: |x/rx| + |y/ry| = 1 + const rx = node.w / 2, ry = node.h / 2; + const t = 1 / (Math.abs(cos) / rx + Math.abs(sin) / ry); + return { x: cx + cos * t, y: cy + sin * t }; + } + + // rect / pill — rectangular bounding box + const rx = node.w / 2, ry = node.h / 2; + const t = Math.abs(sin) * rx > Math.abs(cos) * ry + ? ry / Math.abs(sin) + : rx / Math.abs(cos); + return { x: cx + cos * t, y: cy + sin * t }; +} + +// ── Node drawing ────────────────────────────────────────────────────────────── + +const SHADOW = 3; +const RADIUS = 13; + +export function drawNode( + ctx: CanvasRenderingContext2D, + node: LayoutNode, + colors: ThemeColors, + dark: boolean, + hovered: boolean, +) { + const { x, y, w, h, shape } = node; + const cx = x + w / 2; + const cy = y + h / 2; + + ctx.save(); + + if (hovered && dark) { + ctx.shadowBlur = 20; + ctx.shadowColor = colors.accent; + } + + const nodeFill = resolveNodeColor(node.color, colors); + const hasCustom = Boolean(node.color); + const borderCol = hovered ? colors.accent : hasCustom ? nodeFill : colors.border; + + // ── Shape body ────────────────────────────────────────────────────────── + switch (shape) { + case "rect": { + ctx.fillStyle = colors.shadowHard; + rrect(ctx, x + SHADOW, y + SHADOW, w, h, RADIUS); + ctx.fill(); + + ctx.fillStyle = nodeFill; + rrect(ctx, x, y, w, h, RADIUS); + ctx.fill(); + + ctx.strokeStyle = borderCol; + ctx.lineWidth = hasCustom ? 1.75 : 1.25; + rrect(ctx, x, y, w, h, RADIUS); + ctx.stroke(); + break; + } + + case "pill": { + ctx.fillStyle = colors.shadowHard; + rrect(ctx, x + SHADOW, y + SHADOW, w, h, h / 2); + ctx.fill(); + + ctx.fillStyle = nodeFill; + rrect(ctx, x, y, w, h, h / 2); + ctx.fill(); + + ctx.strokeStyle = borderCol; + ctx.lineWidth = hasCustom ? 1.75 : 1.25; + rrect(ctx, x, y, w, h, h / 2); + ctx.stroke(); + break; + } + + case "circle": { + const r = w / 2; + + ctx.fillStyle = colors.shadowHard; + ctx.beginPath(); + ctx.arc(cx + SHADOW, cy + SHADOW, r, 0, Math.PI * 2); + ctx.fill(); + + if (hasCustom) { + ctx.fillStyle = nodeFill; + } else { + const grad = ctx.createRadialGradient(cx - r * 0.25, cy - r * 0.25, 0, cx, cy, r); + grad.addColorStop(0, colors.muted); + grad.addColorStop(1, dark ? colors.bg : colors.shadowHard); + ctx.fillStyle = grad; + } + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = borderCol; + ctx.lineWidth = hasCustom ? 1.75 : 1.25; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + break; + } + + case "diamond": { + const rx = w / 2, ry = h / 2; + + ctx.fillStyle = colors.shadowHard; + ctx.beginPath(); + ctx.moveTo(cx + SHADOW, cy + SHADOW - ry); + ctx.lineTo(cx + SHADOW + rx, cy + SHADOW); + ctx.lineTo(cx + SHADOW, cy + SHADOW + ry); + ctx.lineTo(cx + SHADOW - rx, cy + SHADOW); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = nodeFill; + ctx.beginPath(); + ctx.moveTo(cx, cy - ry); + ctx.lineTo(cx + rx, cy); + ctx.lineTo(cx, cy + ry); + ctx.lineTo(cx - rx, cy); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = borderCol; + ctx.lineWidth = hasCustom ? 1.75 : 1.25; + ctx.beginPath(); + ctx.moveTo(cx, cy - ry); + ctx.lineTo(cx + rx, cy); + ctx.lineTo(cx, cy + ry); + ctx.lineTo(cx - rx, cy); + ctx.closePath(); + ctx.stroke(); + break; + } + } + + ctx.restore(); + + // ── Text ──────────────────────────────────────────────────────────────── + ctx.save(); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + const hasNote = Boolean(node.note); + const labelY = hasNote ? cy - 10 : cy; + + ctx.fillStyle = colors.fg; + ctx.font = "italic 14px Newsreader, Georgia, serif"; + ctx.fillText(node.label, cx, labelY, w - 24); + + if (node.note) { + ctx.fillStyle = colors.mutedFg; + ctx.font = "11px Inter, system-ui, sans-serif"; + ctx.fillText(node.note, cx, cy + 10, w - 28); + } + + ctx.restore(); +} + +// ── Edge drawing ────────────────────────────────────────────────────────────── + +const HEAD_LEN = 9; +const HEAD_SPREAD = 0.38; + +function arrowhead( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + angle: number, + color: string, +) { + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo( + x - HEAD_LEN * Math.cos(angle - HEAD_SPREAD), + y - HEAD_LEN * Math.sin(angle - HEAD_SPREAD), + ); + ctx.lineTo( + x - HEAD_LEN * Math.cos(angle + HEAD_SPREAD), + y - HEAD_LEN * Math.sin(angle + HEAD_SPREAD), + ); + ctx.closePath(); + ctx.fill(); +} + +function edgePoints(srcNode: LayoutNode, tgtNode: LayoutNode) { + const angle = Math.atan2( + (tgtNode.y + tgtNode.h / 2) - (srcNode.y + srcNode.h / 2), + (tgtNode.x + tgtNode.w / 2) - (srcNode.x + srcNode.w / 2), + ); + return { + angle, + from: boundaryPoint(srcNode, angle), + to: boundaryPoint(tgtNode, angle + Math.PI), + }; +} + +// Pass 1 — draw bezier lines only +export function drawEdgeLine( + ctx: CanvasRenderingContext2D, + edge: DiagramEdge, + srcNode: LayoutNode, + tgtNode: LayoutNode, + colors: ThemeColors, +) { + const { angle, from, to } = edgePoints(srcNode, tgtNode); + const cpDelta = Math.max(Math.abs(to.x - from.x) * 0.45, Math.abs(to.y - from.y) * 0.45, 40); + + ctx.save(); + ctx.globalAlpha = 0.55; + ctx.strokeStyle = colors.mutedFg; + ctx.lineWidth = 1.25; + ctx.lineCap = "round"; + + switch (edge.style) { + case "dashed": ctx.setLineDash([8, 5]); break; + case "dotted": ctx.setLineDash([2, 5]); break; + default: ctx.setLineDash([]); break; + } + + ctx.beginPath(); + ctx.moveTo(from.x, from.y); + ctx.bezierCurveTo( + from.x + cpDelta * Math.cos(angle), from.y + cpDelta * Math.sin(angle), + to.x - cpDelta * Math.cos(angle), to.y - cpDelta * Math.sin(angle), + to.x, to.y, + ); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + + if (edge.arrow === "forward" || edge.arrow === "both") { + arrowhead(ctx, to.x, to.y, angle, colors.mutedFg); + } + if (edge.arrow === "both") { + arrowhead(ctx, from.x, from.y, angle + Math.PI, colors.mutedFg); + } +} + +// Pass 2 — draw label above the line at the bezier midpoint +export function drawEdgeLabel( + ctx: CanvasRenderingContext2D, + edge: DiagramEdge, + srcNode: LayoutNode, + tgtNode: LayoutNode, + colors: ThemeColors, +) { + if (!edge.label) return; + + const { angle, from, to } = edgePoints(srcNode, tgtNode); + const cpDelta = Math.max(Math.abs(to.x - from.x) * 0.45, Math.abs(to.y - from.y) * 0.45, 40); + + // Bezier midpoint at t=0.5 + const p1x = from.x + cpDelta * Math.cos(angle); + const p1y = from.y + cpDelta * Math.sin(angle); + const p2x = to.x - cpDelta * Math.cos(angle); + const p2y = to.y - cpDelta * Math.sin(angle); + const midX = 0.125 * from.x + 0.375 * p1x + 0.375 * p2x + 0.125 * to.x; + const midY = 0.125 * from.y + 0.375 * p1y + 0.375 * p2y + 0.125 * to.y; + + // Perpendicular offset — always 13px above the line in screen space + const labelY = midY - 13; + + ctx.save(); + ctx.font = "10px \"JetBrains Mono\", monospace"; + const tw = ctx.measureText(edge.label).width; + const pad = 5; + + // Slim translucent background so the line doesn't show through the text + ctx.globalAlpha = 0.88; + ctx.fillStyle = colors.bg; + ctx.fillRect(midX - tw / 2 - pad, labelY - 7, tw + pad * 2, 14); + ctx.globalAlpha = 1; + + ctx.fillStyle = colors.mutedFg; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(edge.label, midX, labelY); + ctx.restore(); +} + +// ── Frame ───────────────────────────────────────────────────────────────────── + +export function renderFrame(params: { + ctx: CanvasRenderingContext2D; + w: number; + h: number; + layoutNodes: LayoutNode[]; + edges: DiagramEdge[]; + nodeMap: Map; + colors: ThemeColors; + dark: boolean; + transform: Transform; + hoveredId: string | null; +}) { + const { ctx, w, h, layoutNodes, edges, nodeMap, colors, dark, transform, hoveredId } = params; + + ctx.fillStyle = colors.bg; + ctx.fillRect(0, 0, w, h); + + ctx.save(); + ctx.translate(transform.x, transform.y); + ctx.scale(transform.k, transform.k); + + // Pass 1 — all bezier lines + arrowheads + for (const edge of edges) { + const src = nodeMap.get(edge.from); + const tgt = nodeMap.get(edge.to); + if (!src || !tgt) continue; + drawEdgeLine(ctx, edge, src, tgt, colors); + } + + // Pass 2 — all edge labels (pill backgrounds cover the lines) + for (const edge of edges) { + const src = nodeMap.get(edge.from); + const tgt = nodeMap.get(edge.to); + if (!src || !tgt) continue; + drawEdgeLabel(ctx, edge, src, tgt, colors); + } + + // Pass 3 — nodes on top of everything + for (const node of layoutNodes) { + drawNode(ctx, node, colors, dark, hoveredId === node.id); + } + + ctx.restore(); +} diff --git a/src/components/diagram/toml-editor.tsx b/src/components/diagram/toml-editor.tsx new file mode 100644 index 0000000..b962993 --- /dev/null +++ b/src/components/diagram/toml-editor.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useState, useEffect, useRef } from "react" +import { parse } from "smol-toml" + +interface Props { + value: string + onChange: (v: string) => void + onSave: () => void +} + +interface TomlError { + message: string + line?: number +} + +function parseToml(src: string): TomlError | null { + if (!src.trim()) return null + try { + parse(src) + return null + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + // smol-toml includes "at line X" in the message + const lineMatch = /line (\d+)/i.exec(msg) + return { message: msg, line: lineMatch ? parseInt(lineMatch[1]) : undefined } + } +} + +export default function DiagramTomlEditor({ value, onChange, onSave }: Props) { + const [error, setError] = useState(null) + const [saved, setSaved] = useState(true) + const textareaRef = useRef(null) + + useEffect(() => { + setError(parseToml(value)) + }, [value]) + + function handleChange(v: string) { + onChange(v) + setSaved(false) + setError(parseToml(v)) + } + + function handleSave() { + if (error) return + onSave() + setSaved(true) + } + + // ⌘S / Ctrl+S + function handleKey(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault() + handleSave() + } + } + + const lineCount = value.split("\n").length + + return ( +
+
+ {/* Zeilennummern */} +
+ {Array.from({ length: lineCount }, (_, i) => ( +
+ {i + 1} +
+ ))} +
+ +