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
This commit is contained in:
commit
3f9553cff4
44 changed files with 9000 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||
241
CLAUDE.md
Normal file
241
CLAUDE.md
Normal file
|
|
@ -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 → ``)
|
||||
|
||||
### 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 <token>`.
|
||||
|
||||
### 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
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
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.
|
||||
16
next.config.ts
Normal file
16
next.config.ts
Normal file
|
|
@ -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;
|
||||
36
package.json
Normal file
36
package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
1522
pnpm-lock.yaml
Normal file
1522
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
6
postcss.config.mjs
Normal file
6
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
13
public/manifest.json
Normal file
13
public/manifest.json
Normal file
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
665
src/app/editor/page.tsx
Normal file
665
src/app/editor/page.tsx
Normal file
|
|
@ -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<FrontmatterFields | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("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<SelectionCoords | null>(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<string | null>(null)
|
||||
// Diagram-State
|
||||
const [diagram, setDiagram] = useState<(DiagramData & { folderSlug: string; mode: "raw" | "rendered" }) | null>(null)
|
||||
const [diagramToml, setDiagramToml] = useState("")
|
||||
const engineRef = useRef<EditorEngine | null>(null)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<div className="h-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
{/* Overlays */}
|
||||
<FloatingToolbar engineRef={engineRef} coords={selectionCoords} />
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
|
||||
{/* ── Lösch-Bestätigung ─────────────────────── */}
|
||||
{deleteSlug && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
style={{ background: "oklch(from var(--background) l c h / 0.6)", backdropFilter: "blur(8px)" }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDeleteSlug(null) }}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl border border-border/60 p-6 w-80 flex flex-col gap-4"
|
||||
style={{
|
||||
background: "oklch(from var(--surface) l c h / 0.92)",
|
||||
backdropFilter: "blur(20px)",
|
||||
boxShadow: "0 16px 48px oklch(0% 0 0 / 0.3)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-1">{t.deleteNote}</p>
|
||||
<p className="text-xs text-muted-fg font-mono truncate opacity-60">{deleteSlug}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-fg">{t.deleteWarning}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteSlug(null)}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-border/60 text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{t.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-red-600/80 hover:bg-red-600 text-white transition-colors"
|
||||
>
|
||||
{t.delete}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CommandPalette
|
||||
open={searchOpen} onClose={() => setSearchOpen(false)}
|
||||
onSelectNote={handleSelectNote}
|
||||
notes={topics.flatMap(t => t.notes)} drafts={drafts} topics={topics}
|
||||
/>
|
||||
|
||||
{/* ── Floating navbar ───────────────────────── */}
|
||||
<div className="px-2 pt-2 shrink-0 z-10">
|
||||
<header
|
||||
className="flex items-center gap-0.5 h-10 px-2 rounded-xl border border-border/60 overflow-hidden"
|
||||
style={{
|
||||
background: "oklch(from var(--surface) l c h / 0.85)",
|
||||
backdropFilter: "blur(16px)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
boxShadow: "0 1px 3px oklch(0% 0 0 / 0.08), 0 0 0 0.5px oklch(from var(--border) l c h / 0.4)",
|
||||
}}
|
||||
>
|
||||
<HeaderBtn onClick={() => setSidebarOpen((v) => !v)} title={t.sidebar}><SidebarIcon /></HeaderBtn>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-sm hover:bg-surface-2 transition-colors shrink-0"
|
||||
title={t.home}
|
||||
style={{ fontFamily: "var(--font-newsreader, Georgia, serif)", fontStyle: "italic", color: "var(--sh-heading)" }}
|
||||
>
|
||||
<OrbitalIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">noz studio</span>
|
||||
</Link>
|
||||
|
||||
<div className="w-px h-4 bg-border/60 mx-1 shrink-0" />
|
||||
|
||||
<span className="text-xs font-mono text-muted-fg/40 truncate" style={{ maxWidth: "9rem" }}>
|
||||
{activeNote?.slug ?? "…"}
|
||||
</span>
|
||||
|
||||
{viewMode !== "rendered" && (
|
||||
<>
|
||||
<div className="w-px h-4 bg-border/60 mx-1 shrink-0" />
|
||||
<FormatButtons engineRef={engineRef} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* View mode switcher */}
|
||||
<div className="flex items-center border border-border/60 rounded-lg overflow-hidden shrink-0">
|
||||
<ViewBtn active={viewMode === "raw"} onClick={() => setViewMode("raw")} title={t.editorOnly}><PenIcon /></ViewBtn>
|
||||
<div className="w-px h-4 bg-border/60" />
|
||||
<ViewBtn active={viewMode === "split"} onClick={() => setViewMode("split")} title={t.split}><SplitIcon /></ViewBtn>
|
||||
<div className="w-px h-4 bg-border/60" />
|
||||
<ViewBtn active={viewMode === "rendered"} onClick={() => setViewMode("rendered")} title={t.previewOnly}><EyeIcon /></ViewBtn>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border/60 mx-1 shrink-0" />
|
||||
|
||||
<HeaderBtn onClick={() => setSearchOpen(true)} title={`${t.search} (⌘K)`}><SearchIcon /></HeaderBtn>
|
||||
<button
|
||||
onClick={() => setLocale(locale === "de" ? "en" : "de")}
|
||||
title="Sprache / Language"
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-[10px] font-mono font-semibold text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors shrink-0 tracking-wide"
|
||||
>
|
||||
{locale === "de" ? "EN" : "DE"}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<HeaderBtn onClick={() => setSettingsOpen(true)} title={`${t.settings} (⌘,)`}><SettingsIcon /></HeaderBtn>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{/* ── Loading / Error overlay ───────────────── */}
|
||||
{loading && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
||||
<span className="text-sm text-muted-fg font-mono animate-pulse">{t.loadingNotes}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="fixed inset-x-0 top-14 z-40 flex justify-center px-4">
|
||||
<div className="text-xs text-red-400 bg-red-900/20 border border-red-700/30 rounded-lg px-3 py-2 font-mono">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main ──────────────────────────────────── */}
|
||||
<div className="flex flex-1 overflow-hidden min-h-0 pt-1.5">
|
||||
|
||||
{/* Sidebar + drag handle */}
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<aside
|
||||
className="shrink-0 flex flex-col overflow-hidden bg-muted rounded-br-xl"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<FolderTree
|
||||
topics={topics}
|
||||
drafts={drafts}
|
||||
activeSlug={activeNote?.slug ?? ""}
|
||||
onSelectNote={handleSelectNote}
|
||||
onRenameNote={handleRenameNote}
|
||||
onMoveNote={handleMoveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onNewNote={handleNewNote}
|
||||
onNewFolder={handleNewFolder}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onSelectDiagram={handleSelectDiagram}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Sidebar resize handle */}
|
||||
<div
|
||||
className="shrink-0 relative cursor-col-resize group"
|
||||
style={{ width: 5 }}
|
||||
onMouseDown={handleSidebarResizeStart}
|
||||
onTouchStart={handleSidebarResizeStart}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 right-0 transition-colors"
|
||||
style={{ background: isSidebarResizing ? "var(--accent)" : "var(--border)", opacity: isSidebarResizing ? 0.8 : 0.4 }}
|
||||
/>
|
||||
<div className="absolute inset-y-0 -left-2 -right-2 z-10" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-1 opacity-0 group-hover:opacity-40 transition-opacity pointer-events-none">
|
||||
{[0,1,2].map((i) => <div key={i} className="w-0.5 h-0.5 rounded-full bg-muted-fg" />)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Editor + Preview */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden min-w-0">
|
||||
|
||||
{/* Frontmatter Panel (nur im Split/Rendered-Modus — Raw zeigt das --- Block inline) */}
|
||||
{frontmatterFields && !diagram && viewMode !== "raw" && (
|
||||
<FrontmatterPanel
|
||||
fields={frontmatterFields}
|
||||
onChange={handleFrontmatterChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||
|
||||
{noteLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-xs text-muted-fg/40 font-mono animate-pulse">{t.loadingNote}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagram-Ansicht */}
|
||||
{!noteLoading && diagram && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 h-8 border-b border-border shrink-0">
|
||||
<span className="text-[10px] font-mono text-muted-fg/40 uppercase tracking-widest truncate flex-1">
|
||||
{diagram.folderSlug}
|
||||
</span>
|
||||
<div className="flex items-center border border-border/60 rounded-lg overflow-hidden">
|
||||
<ViewBtn active={diagram.mode === "rendered"} onClick={() => setDiagram(d => d ? { ...d, mode: "rendered" } : d)} title="Gerendert">
|
||||
<EyeIcon />
|
||||
</ViewBtn>
|
||||
<div className="w-px h-4 bg-border/60" />
|
||||
<ViewBtn active={diagram.mode === "raw"} onClick={() => setDiagram(d => d ? { ...d, mode: "raw" } : d)} title="TOML">
|
||||
<PenIcon />
|
||||
</ViewBtn>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDiagram(null)}
|
||||
title="Schließen"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors text-base leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{diagram.mode === "rendered" && (
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{diagram.nodes.length === 0 ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<span className="text-2xl opacity-20">⊞</span>
|
||||
<p className="text-xs font-mono text-muted-fg/40 text-center">
|
||||
Noch kein Diagramm vorhanden.
|
||||
<br />
|
||||
Wechsle zu TOML und definiere Knoten.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDiagram(d => d ? { ...d, mode: "raw" } : d)}
|
||||
className="text-[11px] font-mono px-3 py-1 rounded border border-border/60 text-muted-fg hover:text-foreground hover:border-border transition-colors"
|
||||
>
|
||||
TOML öffnen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<DiagramCanvas
|
||||
nodes={diagram.nodes}
|
||||
edges={diagram.edges}
|
||||
direction={diagram.direction}
|
||||
onSelectNote={handleSelectNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diagram.mode === "raw" && (
|
||||
<DiagramTomlEditor
|
||||
value={diagramToml}
|
||||
onChange={setDiagramToml}
|
||||
onSave={handleSaveDiagramToml}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!noteLoading && !diagram && viewMode !== "rendered" && (
|
||||
<EditorWrapper
|
||||
key={activeNote?.slug ?? ""}
|
||||
noteSlug={activeNote?.slug ?? ""}
|
||||
initialContent={viewMode === "raw" ? (activeNote?.content ?? "") : activeBody}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onEngine={(e) => { engineRef.current = e }}
|
||||
onSelectionChange={setSelectionCoords}
|
||||
className="flex-1 overflow-hidden min-h-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!diagram && viewMode === "split" && (
|
||||
<>
|
||||
{/* Preview resize handle */}
|
||||
<div
|
||||
className="shrink-0 relative cursor-col-resize group"
|
||||
style={{ width: 5 }}
|
||||
onMouseDown={handlePreviewResizeStart}
|
||||
onTouchStart={handlePreviewResizeStart}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 right-0 transition-colors"
|
||||
style={{ background: isPreviewResizing ? "var(--accent)" : "var(--border)", opacity: isPreviewResizing ? 0.8 : 0.4 }}
|
||||
/>
|
||||
<div className="absolute inset-y-0 -left-2 -right-2 z-10" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col gap-1 opacity-0 group-hover:opacity-40 transition-opacity pointer-events-none">
|
||||
{[0,1,2].map((i) => <div key={i} className="w-0.5 h-0.5 rounded-full bg-muted-fg" />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 overflow-hidden flex flex-col bg-muted rounded-bl-xl" style={{ width: previewWidth }}>
|
||||
<div className="flex items-center px-4 h-8 border-b border-border shrink-0">
|
||||
<span className="text-[10px] font-mono text-muted-fg/40 uppercase tracking-widest">Preview</span>
|
||||
</div>
|
||||
<Preview
|
||||
content={fullContent}
|
||||
className="flex-1"
|
||||
notes={[...topics.flatMap(function flat(t): NoteRef[] { return [...t.notes, ...(t.subTopics?.flatMap(flat) ?? [])] }), ...drafts]}
|
||||
onSelectNote={handleSelectNote}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!diagram && viewMode === "rendered" && (
|
||||
<Preview
|
||||
content={fullContent}
|
||||
className="flex-1"
|
||||
notes={[...topics.flatMap(function flat(t): NoteRef[] { return [...t.notes, ...(t.subTopics?.flatMap(flat) ?? [])] }), ...drafts]}
|
||||
onSelectNote={handleSelectNote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeNote && !diagram && (
|
||||
<StatusBar
|
||||
note={activeNote}
|
||||
wordCount={countWords(content)}
|
||||
saved={saved}
|
||||
isOnline={isOnline}
|
||||
hasPending={hasPending}
|
||||
onDraftToggle={activeNote.draftStage !== "draft" ? handleDraftToggle : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Header helpers ─────────────────────────────── */
|
||||
function HeaderBtn({ onClick, title, children }: { onClick: () => void; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button onClick={onClick} title={title}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors shrink-0">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
function ViewBtn({ onClick, title, active, children }: { onClick: () => void; title?: string; active: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<button onClick={onClick} title={title}
|
||||
className={`w-7 h-7 flex items-center justify-center transition-colors ${active ? "bg-surface-2 text-foreground" : "text-muted-fg hover:text-foreground hover:bg-surface"}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Icons ──────────────────────────────────────── */
|
||||
function OrbitalIcon({ className }: { className?: string }) {
|
||||
return <svg viewBox="0 0 48 48" fill="none" className={className} style={{ color: "var(--accent)" }}><circle cx="24" cy="24" r="4" fill="currentColor"/><ellipse cx="24" cy="24" rx="19" ry="8" stroke="currentColor" strokeWidth="1.5" transform="rotate(-35 24 24)" opacity="0.8"/><circle cx="37" cy="13.5" r="2.5" fill="currentColor" opacity="0.6"/></svg>
|
||||
}
|
||||
function SidebarIcon() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg> }
|
||||
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> }
|
||||
function SettingsIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> }
|
||||
function PenIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> }
|
||||
function SplitIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg> }
|
||||
function EyeIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> }
|
||||
306
src/app/globals.css
Normal file
306
src/app/globals.css
Normal file
|
|
@ -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); }
|
||||
}
|
||||
45
src/app/layout.tsx
Normal file
45
src/app/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<html
|
||||
lang="de"
|
||||
suppressHydrationWarning
|
||||
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
<meta name="theme-color" content="#f7f5f2" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0a0d18" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
<Script id="theme-init" strategy="beforeInteractive">{themeScript}</Script>
|
||||
</head>
|
||||
<body className="antialiased overflow-hidden h-screen bg-background text-foreground">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
216
src/app/login/page.tsx
Normal file
216
src/app/login/page.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background relative overflow-hidden">
|
||||
{/* Nebula glows */}
|
||||
<div className="absolute pointer-events-none" style={{
|
||||
top: "-15%", left: "50%", transform: "translateX(-50%)",
|
||||
width: "700px", height: "350px",
|
||||
background: "radial-gradient(ellipse, oklch(77% 0.13 253 / 0.08), transparent 70%)",
|
||||
borderRadius: "50%",
|
||||
}} />
|
||||
<div className="absolute pointer-events-none" style={{
|
||||
bottom: "-10%", right: "10%",
|
||||
width: "400px", height: "300px",
|
||||
background: "radial-gradient(ellipse, oklch(72% 0.2 290 / 0.04), transparent 70%)",
|
||||
borderRadius: "50%",
|
||||
}} />
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative w-full max-w-sm mx-5 z-10">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<OrbitalIcon className="w-12 h-12 mb-4" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl tracking-tight" style={{
|
||||
fontFamily: "var(--font-newsreader, Georgia, serif)",
|
||||
fontStyle: "italic",
|
||||
color: "oklch(93% 0.04 258)",
|
||||
}}>
|
||||
noz
|
||||
</span>
|
||||
<span className="text-xs font-mono text-muted-fg/60 uppercase tracking-[0.2em]">
|
||||
studio
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect}>
|
||||
<div className="bg-muted border border-border rounded-xl overflow-hidden">
|
||||
<div className="p-6 space-y-4">
|
||||
|
||||
{/* Fehlermeldung */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2.5 px-3 py-2.5 rounded-lg text-xs font-mono"
|
||||
style={{ background: "oklch(from var(--draft) l c h / 0.1)", color: "var(--draft)", border: "1px solid oklch(from var(--draft) l c h / 0.25)" }}>
|
||||
<ErrorIcon />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
|
||||
Server
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
|
||||
Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !url || !token}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-medium transition-all active:scale-[0.98] bg-accent text-accent-fg hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<SpinnerIcon />
|
||||
<span>Verbinde…</span>
|
||||
</>
|
||||
) : (
|
||||
"Verbinden"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-3 px-6 pb-1">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-[11px] text-muted-fg/40 font-mono">oder</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Authentik — noch nicht implementiert */}
|
||||
<div className="p-4 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title="Noch nicht verfügbar"
|
||||
className="w-full flex items-center justify-center gap-2.5 py-2.5 border border-border rounded-lg text-sm text-muted-fg/40 cursor-not-allowed"
|
||||
>
|
||||
<AuthentikIcon />
|
||||
<span>Mit Authentik anmelden</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[11px] text-muted-fg/30 mt-5 font-mono">
|
||||
Token aus{" "}
|
||||
<code className="text-muted-fg/50">noosphere.toml [cli]</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Icons ──────────────────────────────────────────── */
|
||||
|
||||
function OrbitalIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" fill="none" className={className} style={{ color: "oklch(77% 0.13 253)" }}>
|
||||
<circle cx="24" cy="24" r="4" fill="currentColor" />
|
||||
<ellipse cx="24" cy="24" rx="19" ry="8" stroke="currentColor" strokeWidth="1.5" transform="rotate(-35 24 24)" opacity="0.8" />
|
||||
<circle cx="37" cy="13.5" r="2.5" fill="currentColor" opacity="0.6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthentikIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 mt-0.5">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SpinnerIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="animate-spin shrink-0">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
184
src/app/page.tsx
Normal file
184
src/app/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="h-screen flex flex-col bg-background text-foreground overflow-hidden relative">
|
||||
{/* Starfield — dark mode only */}
|
||||
<div className="hidden dark:block">
|
||||
<StarField />
|
||||
</div>
|
||||
|
||||
{/* Nebula glows */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden>
|
||||
{/* Main top glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-15%",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 900,
|
||||
height: 600,
|
||||
background: "radial-gradient(ellipse, color-mix(in oklch, var(--accent) 14%, transparent), transparent 65%)",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
{/* Secondary glow bottom-right */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-25%",
|
||||
right: "-5%",
|
||||
width: 500,
|
||||
height: 500,
|
||||
background: "radial-gradient(ellipse, color-mix(in oklch, var(--budding) 6%, transparent), transparent 70%)",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
{/* Tertiary glow bottom-left */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-20%",
|
||||
left: "-5%",
|
||||
width: 400,
|
||||
height: 400,
|
||||
background: "radial-gradient(ellipse, color-mix(in oklch, var(--evergreen) 5%, transparent), transparent 70%)",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-end px-5 py-3 shrink-0 relative z-10">
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
{/* Center content */}
|
||||
<main className="flex-1 flex flex-col items-center justify-center gap-8 relative z-10 px-6">
|
||||
|
||||
{/* Orbital logo */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* Glow behind logo */}
|
||||
<div
|
||||
className="absolute rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: 240,
|
||||
height: 240,
|
||||
background: "radial-gradient(ellipse, color-mix(in oklch, var(--accent) 18%, transparent), transparent 70%)",
|
||||
filter: "blur(20px)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ animation: "orbital-breathe 5s ease-in-out infinite", position: "relative" }}>
|
||||
<svg
|
||||
width="96"
|
||||
height="96"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
<circle cx="24" cy="24" r="4.5" fill="currentColor" />
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="19"
|
||||
ry="7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
transform="rotate(-35 24 24)"
|
||||
opacity="0.55"
|
||||
/>
|
||||
{/* Second orbit ring, subtle */}
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="14"
|
||||
ry="5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.6"
|
||||
transform="rotate(55 24 24)"
|
||||
opacity="0.2"
|
||||
/>
|
||||
<circle cx="37.2" cy="13.8" r="2.8" fill="currentColor" opacity="0.5" />
|
||||
<circle cx="12.5" cy="32.5" r="1.5" fill="currentColor" opacity="0.3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title block */}
|
||||
<div className="text-center space-y-3 -mt-2">
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: "var(--font-newsreader, Georgia, serif)",
|
||||
fontStyle: "italic",
|
||||
color: "var(--sh-heading)",
|
||||
fontSize: "clamp(3.2rem, 8vw, 5.5rem)",
|
||||
lineHeight: 1,
|
||||
letterSpacing: "-0.03em",
|
||||
}}
|
||||
>
|
||||
noz studio
|
||||
</h1>
|
||||
<p className="text-sm text-muted-fg font-mono tracking-widest">
|
||||
Schreiben · Denken · Veröffentlichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature chips */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{["Markdown", "KaTeX", "Wikilinks", "Syntax-Highlighting", "PWA", "Dark Mode"].map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[11px] font-mono px-2.5 py-1 rounded-full border border-border/60 text-muted-fg/50"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 mt-1">
|
||||
<Link
|
||||
href="/editor"
|
||||
className="flex items-center gap-2 px-7 py-3 rounded-xl text-sm font-medium transition-all active:scale-[0.97] hover:opacity-90"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--accent-fg)",
|
||||
boxShadow: "0 0 40px color-mix(in oklch, var(--accent) 30%, transparent)",
|
||||
}}
|
||||
>
|
||||
Studio öffnen
|
||||
<ArrowIcon />
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex items-center gap-2 px-7 py-3 rounded-xl text-sm border border-border text-muted-fg hover:text-foreground hover:bg-surface transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<p className="text-[11px] text-muted-fg/30 font-mono mt-2 text-center max-w-xs">
|
||||
Verbinde dich mit deiner noz-Instanz und schreibe von überall.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="shrink-0 flex items-center justify-center pb-5 relative z-10">
|
||||
<span className="text-[11px] font-mono text-muted-fg/20">v0.1.0</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
328
src/components/diagram/canvas.tsx
Normal file
328
src/components/diagram/canvas.tsx
Normal file
|
|
@ -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<HTMLCanvasElement>(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<string, LayoutNode>(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<number, { x: number; y: number }>()
|
||||
const touchStart = new Map<number, { x: number; y: number }>()
|
||||
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 (
|
||||
<div className={containerCls}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full block touch-none"
|
||||
aria-label="Ordner-Diagramm"
|
||||
/>
|
||||
{/* Zoom-Controls */}
|
||||
<div style={{ position: "absolute", bottom: 16, right: 16, zIndex: 10, display: "flex", flexDirection: "column", borderRadius: 12, border: "1px solid var(--border)", background: "var(--background)", overflow: "hidden", userSelect: "none" }}>
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={i}>
|
||||
{i > 0 && <div style={{ height: 1, background: "var(--border)", margin: "0 6px" }} />}
|
||||
<button onClick={btn.fn} title={btn.title} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 32, height: 32, background: "transparent", border: "none", cursor: "pointer", color: "var(--muted-fg)" }}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
<path d={btn.icon} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: 1, background: "var(--border)", margin: "0 6px" }} />
|
||||
<button onClick={toggleFullscreen} title={fullscreen ? "Vollbild verlassen" : "Vollbild"} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 32, height: 32, background: "transparent", border: "none", cursor: "pointer", color: "var(--muted-fg)" }}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
||||
{fullscreen
|
||||
? <path d="M4.5 1.5H1.5v3M7.5 1.5h3v3M4.5 10.5H1.5v-3M7.5 10.5h3v-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
: <path d="M1.5 4.5V1.5h3M7.5 1.5h3v3M1.5 7.5v3h3M10.5 7.5v3h-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
src/components/diagram/layout.ts
Normal file
101
src/components/diagram/layout.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { DiagramNode, DiagramEdge, DiagramDirection, LayoutNode } from "./types";
|
||||
|
||||
export const NODE_DIMS: Record<string, { w: number; h: number }> = {
|
||||
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<string, string[]>(ids.map(id => [id, []]));
|
||||
const inDegree = new Map<string, number>(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<string, number>(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<number, string[]>();
|
||||
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;
|
||||
}
|
||||
375
src/components/diagram/render.ts
Normal file
375
src/components/diagram/render.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import type { LayoutNode, DiagramEdge, ThemeColors, Transform } from "./types";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
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<string, LayoutNode>;
|
||||
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();
|
||||
}
|
||||
133
src/components/diagram/toml-editor.tsx
Normal file
133
src/components/diagram/toml-editor.tsx
Normal file
|
|
@ -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<TomlError | null>(null)
|
||||
const [saved, setSaved] = useState(true)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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 (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden relative">
|
||||
{/* Zeilennummern */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="shrink-0 select-none text-right font-mono text-[11px] leading-5 pr-3 pt-4 pb-4 pl-3 overflow-hidden"
|
||||
style={{ color: "var(--muted-fg)", opacity: 0.3, minWidth: 40 }}
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={error?.line === i + 1 ? { color: "var(--red, #f87171)", opacity: 1 } : undefined}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
className="flex-1 py-4 pr-4 font-mono text-[11px] leading-5 resize-none outline-none overflow-auto"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
border: "none",
|
||||
borderLeft: error ? "2px solid var(--red, #f87171)" : "2px solid transparent",
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer: Fehler + Speichern */}
|
||||
<div
|
||||
className="shrink-0 flex items-start gap-2 px-4 py-2 border-t"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{error ? (
|
||||
<p className="text-[10px] font-mono leading-relaxed" style={{ color: "var(--red, #f87171)" }}>
|
||||
{error.line && <span className="opacity-60">Zeile {error.line}: </span>}
|
||||
{error.message.replace(/at line \d+/i, "").trim()}
|
||||
</p>
|
||||
) : value.trim() ? (
|
||||
<p className="text-[10px] font-mono" style={{ color: "var(--evergreen)" }}>✓ TOML gültig</p>
|
||||
) : (
|
||||
<p className="text-[10px] font-mono" style={{ color: "var(--muted-fg)", opacity: 0.4 }}>
|
||||
Leer — Knoten und Kanten definieren
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!!error || saved}
|
||||
className="shrink-0 text-[11px] font-mono px-3 py-1 rounded transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: error ? "var(--surface-2)" : "var(--accent)",
|
||||
color: error ? "var(--muted-fg)" : "white",
|
||||
cursor: error ? "not-allowed" : "pointer",
|
||||
}}
|
||||
title={error ? "TOML-Fehler — Speichern nicht möglich" : "Speichern (⌘S)"}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/components/diagram/types.ts
Normal file
41
src/components/diagram/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export type DiagramDirection = "LR" | "TB";
|
||||
|
||||
export interface DiagramNode {
|
||||
id: string;
|
||||
slug?: string;
|
||||
shape: "circle" | "rect" | "diamond" | "pill";
|
||||
label: string;
|
||||
note?: string;
|
||||
color?: string; // CSS color string or "seed" | "budding" | "evergreen"
|
||||
}
|
||||
|
||||
export interface DiagramEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
arrow: "forward" | "both" | "none";
|
||||
style: "solid" | "dashed" | "dotted";
|
||||
}
|
||||
|
||||
export interface LayoutNode extends DiagramNode {
|
||||
x: number; // top-left of bounding box (canvas units)
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface ThemeColors {
|
||||
bg: string;
|
||||
fg: string;
|
||||
muted: string;
|
||||
mutedFg: string;
|
||||
border: string;
|
||||
accent: string;
|
||||
shadowHard: string;
|
||||
}
|
||||
|
||||
export interface Transform {
|
||||
x: number;
|
||||
y: number;
|
||||
k: number;
|
||||
}
|
||||
386
src/components/editor/cm6-adapter.ts
Normal file
386
src/components/editor/cm6-adapter.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import {
|
||||
EditorView,
|
||||
ViewUpdate,
|
||||
ViewPlugin,
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
keymap,
|
||||
placeholder,
|
||||
} from "@codemirror/view"
|
||||
import { EditorState } from "@codemirror/state"
|
||||
import type { Range } from "@codemirror/state"
|
||||
import { markdown, markdownLanguage } from "@codemirror/lang-markdown"
|
||||
import { syntaxHighlighting, HighlightStyle, syntaxTree } from "@codemirror/language"
|
||||
import { languages } from "@codemirror/language-data"
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
indentWithTab,
|
||||
} from "@codemirror/commands"
|
||||
import {
|
||||
autocompletion,
|
||||
CompletionContext,
|
||||
type Completion,
|
||||
} from "@codemirror/autocomplete"
|
||||
import { tags } from "@lezer/highlight"
|
||||
import type { EditorEngine, SelectionCoords, NoteRef } from "./engine"
|
||||
|
||||
/* ── Studio theme — CSS variables, works in light + dark ── */
|
||||
const studioTheme = EditorView.theme({
|
||||
"&": { backgroundColor: "transparent", height: "100%", fontSize: "15px" },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
height: "100%",
|
||||
fontFamily: "var(--font-jetbrains, 'JetBrains Mono', monospace)",
|
||||
},
|
||||
".cm-content": {
|
||||
fontFamily: "var(--font-jetbrains, 'JetBrains Mono', monospace)",
|
||||
lineHeight: "1.85",
|
||||
padding: "2.5rem clamp(1.5rem, 6%, 5rem)",
|
||||
maxWidth: "76ch",
|
||||
margin: "0 auto",
|
||||
caretColor: "var(--accent)",
|
||||
minHeight: "100%",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--accent)",
|
||||
borderLeftWidth: "2px",
|
||||
},
|
||||
"&.cm-focused .cm-cursor": { borderLeftColor: "var(--accent)" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
|
||||
backgroundColor: "color-mix(in oklch, var(--accent) 18%, transparent)",
|
||||
},
|
||||
"& ::selection": {
|
||||
backgroundColor: "color-mix(in oklch, var(--accent) 18%, transparent)",
|
||||
},
|
||||
".cm-line": { color: "var(--foreground)", paddingLeft: "0", paddingRight: "0" },
|
||||
".cm-activeLine": { backgroundColor: "transparent" },
|
||||
".cm-gutters": { display: "none" },
|
||||
".cm-placeholder": { color: "var(--muted-fg)", fontStyle: "italic", opacity: "0.5" },
|
||||
})
|
||||
|
||||
/* ── Syntax highlight — --sh-* CSS vars ───────────────── */
|
||||
const studioHighlight = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.heading1,
|
||||
color: "var(--sh-heading)",
|
||||
fontSize: "1.5em",
|
||||
fontWeight: "400",
|
||||
fontStyle: "italic",
|
||||
fontFamily: "var(--font-newsreader, Georgia, serif)",
|
||||
lineHeight: "1.3",
|
||||
},
|
||||
{
|
||||
tag: tags.heading2,
|
||||
color: "var(--sh-heading)",
|
||||
fontSize: "1.25em",
|
||||
fontWeight: "400",
|
||||
fontStyle: "italic",
|
||||
fontFamily: "var(--font-newsreader, Georgia, serif)",
|
||||
lineHeight: "1.3",
|
||||
},
|
||||
{
|
||||
tag: tags.heading3,
|
||||
color: "var(--sh-heading)",
|
||||
fontSize: "1.1em",
|
||||
fontWeight: "400",
|
||||
fontStyle: "italic",
|
||||
fontFamily: "var(--font-newsreader, Georgia, serif)",
|
||||
},
|
||||
{ tag: tags.heading4, color: "var(--sh-heading)", fontWeight: "600" },
|
||||
{ tag: tags.strong, fontWeight: "700" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through", color: "var(--sh-strike)" },
|
||||
{
|
||||
tag: tags.monospace,
|
||||
fontFamily: "var(--font-jetbrains, monospace)",
|
||||
color: "var(--sh-code)",
|
||||
fontSize: "0.88em",
|
||||
},
|
||||
{ tag: tags.link, color: "var(--sh-link)", textDecoration: "underline", textUnderlineOffset: "2px" },
|
||||
{ tag: tags.url, color: "var(--sh-link)", opacity: "0.7" },
|
||||
{ tag: tags.quote, color: "var(--sh-quote)", fontStyle: "italic" },
|
||||
{ tag: tags.processingInstruction, color: "var(--sh-punct)" },
|
||||
{ tag: tags.punctuation, color: "var(--sh-punct)" },
|
||||
{ tag: tags.meta, color: "var(--sh-punct)" },
|
||||
/* Code block token colors */
|
||||
{ tag: tags.keyword, color: "var(--sh-math)", fontWeight: "500" },
|
||||
{ tag: tags.string, color: "var(--sh-link)" },
|
||||
{ tag: tags.number, color: "var(--sh-code)" },
|
||||
{ tag: tags.comment, color: "var(--sh-quote)", fontStyle: "italic" },
|
||||
{ tag: tags.variableName, color: "var(--foreground)" },
|
||||
{ tag: tags.typeName, color: "var(--sh-heading)" },
|
||||
{ tag: tags.function(tags.variableName), color: "var(--sh-code)" },
|
||||
{ tag: tags.operator, color: "var(--sh-punct)" },
|
||||
{ tag: tags.bool, color: "var(--sh-math)" },
|
||||
{ tag: tags.content, color: "var(--foreground)" },
|
||||
])
|
||||
|
||||
/* ── Typora-style: hide markdown markers outside cursor ── */
|
||||
const hideMark = Decoration.replace({})
|
||||
|
||||
function buildTyporaDecos(view: EditorView): DecorationSet {
|
||||
const { state } = view
|
||||
const { from: sel0, to: sel1 } = state.selection.main
|
||||
const cursorIn = (from: number, to: number) => sel0 <= to && sel1 >= from
|
||||
const decos: Range<Decoration>[] = []
|
||||
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(state).iterate({
|
||||
from, to,
|
||||
enter(node) {
|
||||
switch (node.name) {
|
||||
case "EmphasisMark":
|
||||
case "StrikethroughMark": {
|
||||
const parent = node.node.parent
|
||||
if (parent && !cursorIn(parent.from, parent.to))
|
||||
decos.push(hideMark.range(node.from, node.to))
|
||||
break
|
||||
}
|
||||
case "CodeMark": {
|
||||
const parent = node.node.parent
|
||||
if (parent?.name === "InlineCode" && !cursorIn(parent.from, parent.to))
|
||||
decos.push(hideMark.range(node.from, node.to))
|
||||
break
|
||||
}
|
||||
case "HeaderMark": {
|
||||
const line = state.doc.lineAt(node.from)
|
||||
if (!cursorIn(line.from, line.to)) {
|
||||
const end = state.sliceDoc(node.to, node.to + 1) === " " ? node.to + 1 : node.to
|
||||
decos.push(hideMark.range(node.from, end))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return Decoration.set(decos, true)
|
||||
}
|
||||
|
||||
const typoraPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
constructor(view: EditorView) { this.decorations = buildTyporaDecos(view) }
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged || u.selectionSet || u.viewportChanged)
|
||||
this.decorations = buildTyporaDecos(u.view)
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
)
|
||||
|
||||
/* ── Autocomplete theme ────────────────────────────────── */
|
||||
const autocompleteTheme = EditorView.theme({
|
||||
".cm-tooltip.cm-tooltip-autocomplete": {
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 8px 32px oklch(0% 0 0 / 0.25)",
|
||||
padding: "4px",
|
||||
fontFamily: "var(--font-jetbrains, monospace)",
|
||||
fontSize: "12px",
|
||||
overflow: "hidden",
|
||||
minWidth: "220px",
|
||||
maxWidth: "380px",
|
||||
},
|
||||
".cm-tooltip-autocomplete > ul": {
|
||||
fontFamily: "inherit",
|
||||
maxHeight: "260px",
|
||||
},
|
||||
".cm-tooltip-autocomplete > ul > li": {
|
||||
padding: "5px 10px",
|
||||
borderRadius: "6px",
|
||||
lineHeight: "1.5",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
},
|
||||
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
|
||||
background: "oklch(from var(--accent) l c h / 0.15)",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-completionLabel": {
|
||||
color: "var(--foreground)",
|
||||
fontWeight: "500",
|
||||
},
|
||||
".cm-completionDetail": {
|
||||
color: "var(--muted-fg)",
|
||||
opacity: "0.6",
|
||||
fontSize: "11px",
|
||||
marginLeft: "auto",
|
||||
fontStyle: "normal",
|
||||
},
|
||||
".cm-completionMatchedText": {
|
||||
color: "var(--accent)",
|
||||
textDecoration: "none",
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Status-Dots via type icons
|
||||
".cm-completionIcon": {
|
||||
width: "16px",
|
||||
paddingRight: "0",
|
||||
opacity: "1",
|
||||
fontSize: "8px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
".cm-completionIcon-seed::after": { content: '"●"', color: "var(--seed)" },
|
||||
".cm-completionIcon-budding::after": { content: '"●"', color: "var(--budding)" },
|
||||
".cm-completionIcon-evergreen::after": { content: '"●"', color: "var(--evergreen)" },
|
||||
".cm-completionIcon-draft::after": { content: '"●"', color: "var(--draft)" },
|
||||
})
|
||||
|
||||
/* ── CM6Adapter ───────────────────────────────────────── */
|
||||
export class CM6Adapter implements EditorEngine {
|
||||
private view: EditorView
|
||||
private saveCallbacks: Array<(value: string) => void> = []
|
||||
private selectionCallbacks: Array<(coords: SelectionCoords | null) => void> = []
|
||||
private suppressChange = false
|
||||
private notes: NoteRef[] = []
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
initialValue: string,
|
||||
onChange?: (value: string) => void
|
||||
) {
|
||||
const self = this
|
||||
|
||||
const saveKeymap = keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
const value = self.view.state.doc.toString()
|
||||
self.saveCallbacks.forEach((cb) => cb(value))
|
||||
return true
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const wikilinkSource = (context: CompletionContext) => {
|
||||
const match = context.matchBefore(/\[\[[^\]]*/)
|
||||
if (!match) return null
|
||||
|
||||
const query = match.text.slice(2).toLowerCase()
|
||||
const notes = self.notes
|
||||
|
||||
const options: Completion[] = notes
|
||||
.filter(n =>
|
||||
query === "" ||
|
||||
n.title.toLowerCase().includes(query) ||
|
||||
n.slug.toLowerCase().includes(query)
|
||||
)
|
||||
.slice(0, 30)
|
||||
.map(n => ({
|
||||
label: n.title,
|
||||
type: n.status, // "seed" | "budding" | "evergreen" → colored dot via CSS
|
||||
detail: n.topic || undefined,
|
||||
boost: n.status === "evergreen" ? 2 : n.status === "budding" ? 1 : 0,
|
||||
apply: (view: EditorView, _c: Completion, _from: number, to: number) => {
|
||||
view.dispatch({
|
||||
changes: { from: match.from, to, insert: `[[${n.title}]]` },
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
if (options.length === 0) return null
|
||||
|
||||
return { from: match.from, filter: false, options }
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: initialValue,
|
||||
extensions: [
|
||||
history(),
|
||||
markdown({ base: markdownLanguage, codeLanguages: languages }),
|
||||
syntaxHighlighting(studioHighlight),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
||||
saveKeymap,
|
||||
studioTheme,
|
||||
autocompleteTheme,
|
||||
typoraPlugin,
|
||||
autocompletion({ override: [wikilinkSource], closeOnBlur: true }),
|
||||
placeholder("Beginne zu schreiben…"),
|
||||
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||
// Content change — suppressed during programmatic setValue calls
|
||||
if (update.docChanged && onChange && !self.suppressChange) {
|
||||
onChange(update.state.doc.toString())
|
||||
}
|
||||
// Selection change → fire floating toolbar coords
|
||||
if (update.selectionSet && self.selectionCallbacks.length > 0) {
|
||||
const { from, to } = update.state.selection.main
|
||||
if (from !== to) {
|
||||
const fromCoords = update.view.coordsAtPos(from)
|
||||
const toCoords = update.view.coordsAtPos(to)
|
||||
if (fromCoords) {
|
||||
self.selectionCallbacks.forEach((cb) =>
|
||||
cb({
|
||||
top: fromCoords.top,
|
||||
bottom: fromCoords.bottom,
|
||||
left: fromCoords.left,
|
||||
right: toCoords ? toCoords.right : fromCoords.right,
|
||||
})
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.selectionCallbacks.forEach((cb) => cb(null))
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
this.view = new EditorView({ state, parent: container })
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.view.state.doc.toString()
|
||||
}
|
||||
|
||||
setValue(content: string): void {
|
||||
this.suppressChange = true
|
||||
this.view.dispatch({
|
||||
changes: { from: 0, to: this.view.state.doc.length, insert: content },
|
||||
})
|
||||
this.suppressChange = false
|
||||
}
|
||||
|
||||
onSave(callback: (value: string) => void): void {
|
||||
this.saveCallbacks.push(callback)
|
||||
}
|
||||
|
||||
onSelectionChange(callback: (coords: SelectionCoords | null) => void): void {
|
||||
this.selectionCallbacks.push(callback)
|
||||
}
|
||||
|
||||
insertAt(pos: number, text: string): void {
|
||||
this.view.dispatch({ changes: { from: pos, insert: text } })
|
||||
}
|
||||
|
||||
wrapSelection(before: string, after: string): void {
|
||||
const { state } = this.view
|
||||
const { from, to } = state.selection.main
|
||||
const selected = state.sliceDoc(from, to)
|
||||
this.view.dispatch({
|
||||
changes: { from, to, insert: `${before}${selected}${after}` },
|
||||
selection: { anchor: from + before.length, head: from + before.length + selected.length },
|
||||
})
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.view.focus()
|
||||
}
|
||||
|
||||
updateNotes(notes: NoteRef[]): void {
|
||||
this.notes = notes
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.view.destroy()
|
||||
}
|
||||
}
|
||||
64
src/components/editor/editor-wrapper.tsx
Normal file
64
src/components/editor/editor-wrapper.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import type { EditorEngine, SelectionCoords } from "./engine"
|
||||
|
||||
interface Props {
|
||||
noteSlug: string
|
||||
initialContent: string
|
||||
onChange: (value: string) => void
|
||||
onSave?: (value: string) => void
|
||||
onEngine?: (engine: EditorEngine) => void
|
||||
onSelectionChange?: (coords: SelectionCoords | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function EditorWrapper({
|
||||
noteSlug: _noteSlug,
|
||||
initialContent,
|
||||
onChange,
|
||||
onSave,
|
||||
onEngine,
|
||||
onSelectionChange,
|
||||
className,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const engineRef = useRef<EditorEngine | null>(null)
|
||||
|
||||
// The parent passes key={slug} so this component remounts on every note switch.
|
||||
// initialContent is derived from activeNote.content via useMemo — always correct
|
||||
// at mount time, no timing gaps.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
let destroyed = false
|
||||
|
||||
import("./cm6-adapter").then(({ CM6Adapter }) => {
|
||||
if (destroyed || !containerRef.current) return
|
||||
const engine = new CM6Adapter(containerRef.current, initialContent, onChange)
|
||||
if (onSave) engine.onSave(onSave)
|
||||
if (onSelectionChange) engine.onSelectionChange(onSelectionChange)
|
||||
engineRef.current = engine
|
||||
onEngine?.(engine)
|
||||
})
|
||||
|
||||
return () => {
|
||||
destroyed = true
|
||||
engineRef.current?.destroy()
|
||||
engineRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ cursor: "text" }}
|
||||
onClick={() => {
|
||||
if (engineRef.current && document.activeElement !== containerRef.current) {
|
||||
engineRef.current.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
25
src/components/editor/engine.ts
Normal file
25
src/components/editor/engine.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export interface SelectionCoords {
|
||||
top: number
|
||||
bottom: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
export interface NoteRef {
|
||||
slug: string
|
||||
title: string
|
||||
topic: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface EditorEngine {
|
||||
getValue(): string
|
||||
setValue(content: string): void
|
||||
onSave(callback: (value: string) => void): void
|
||||
onSelectionChange(callback: (coords: SelectionCoords | null) => void): void
|
||||
insertAt(pos: number, text: string): void
|
||||
wrapSelection(before: string, after: string): void
|
||||
updateNotes?(notes: NoteRef[]): void
|
||||
focus(): void
|
||||
destroy(): void
|
||||
}
|
||||
251
src/components/editor/floating-toolbar.tsx
Normal file
251
src/components/editor/floating-toolbar.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { EditorEngine, SelectionCoords } from "./engine"
|
||||
|
||||
interface Props {
|
||||
engineRef: React.RefObject<EditorEngine | null>
|
||||
coords: SelectionCoords | null
|
||||
}
|
||||
|
||||
const TOOLBAR_WIDTH = 492
|
||||
const TOOLBAR_HEIGHT = 36
|
||||
const GAP = 8
|
||||
|
||||
export default function FloatingToolbar({ engineRef, coords }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!coords) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
const vw = window.innerWidth
|
||||
|
||||
// Prefer above selection — fallback below if too close to top
|
||||
const above = coords.top - TOOLBAR_HEIGHT - GAP
|
||||
const below = coords.bottom + GAP
|
||||
const top = above < 12 ? below : above
|
||||
|
||||
// Horizontal: centered on selection midpoint, clamped to viewport
|
||||
const mid = (coords.left + coords.right) / 2
|
||||
const left = Math.max(8, Math.min(vw - TOOLBAR_WIDTH - 8, mid - TOOLBAR_WIDTH / 2))
|
||||
|
||||
setPos({ top, left })
|
||||
setVisible(true)
|
||||
}, [coords])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const wrap = (before: string, after: string) => {
|
||||
engineRef.current?.wrapSelection(before, after)
|
||||
}
|
||||
|
||||
const prevent = (e: React.MouseEvent) => e.preventDefault()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseDown={prevent}
|
||||
className="fixed z-50 flex items-center gap-0.5 px-1.5 rounded-lg border border-border shadow-lg"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: TOOLBAR_WIDTH,
|
||||
height: TOOLBAR_HEIGHT,
|
||||
background: "var(--surface)",
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<FBtn onClick={() => wrap("**", "**")} title="Fett">
|
||||
<BoldIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("*", "*")} title="Kursiv">
|
||||
<ItalicIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("~~", "~~")} title="Durchgestrichen">
|
||||
<StrikeIcon />
|
||||
</FBtn>
|
||||
|
||||
<FSep />
|
||||
|
||||
<FBtn onClick={() => wrap("\n# ", "")} title="H1">
|
||||
<span className="text-[10px] font-bold font-mono">H1</span>
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("\n## ", "")} title="H2">
|
||||
<span className="text-[10px] font-bold font-mono">H2</span>
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("\n### ", "")} title="H3">
|
||||
<span className="text-[10px] font-bold font-mono">H3</span>
|
||||
</FBtn>
|
||||
|
||||
<FSep />
|
||||
|
||||
<FBtn onClick={() => wrap("[", "](url)")} title="Link">
|
||||
<LinkIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("")} title="Bild">
|
||||
<ImageIcon />
|
||||
</FBtn>
|
||||
|
||||
<FSep />
|
||||
|
||||
<FBtn onClick={() => wrap("`", "`")} title="Code">
|
||||
<CodeIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("$", "$")} title="Formel">
|
||||
<MathIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("[[", "]]")} title="Wikilink">
|
||||
<WikiIcon />
|
||||
</FBtn>
|
||||
|
||||
<FSep />
|
||||
|
||||
<FBtn onClick={() => wrap("\n> ", "")} title="Zitat">
|
||||
<QuoteIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("\n- ", "")} title="Liste">
|
||||
<BulletIcon />
|
||||
</FBtn>
|
||||
<FBtn onClick={() => wrap("\n1. ", "")} title="Nummerierte Liste">
|
||||
<NumberedIcon />
|
||||
</FBtn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Subcomponents ──────────────────────────────────── */
|
||||
|
||||
function FBtn({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
title={title}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // keep editor focus + selection
|
||||
onClick()
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.preventDefault()
|
||||
onClick()
|
||||
}}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors shrink-0"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FSep() {
|
||||
return <div className="w-px h-4 bg-border mx-0.5 shrink-0" />
|
||||
}
|
||||
|
||||
/* ── Icons (mini) ───────────────────────────────────── */
|
||||
function BoldIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function ItalicIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function StrikeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<path d="M16 6C16 6 14.5 4 12 4C9.5 4 7 5.5 7 8" />
|
||||
<path d="M8 18C8 18 9.5 20 12 20C14.5 20 17 18.5 17 16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function LinkIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function CodeIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function MathIcon() {
|
||||
return (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19 8 5" /><path d="M20 19 16 5" /><path d="M3 12h18" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function WikiIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="6" width="5" height="12" rx="1" />
|
||||
<rect x="17" y="6" width="5" height="12" rx="1" />
|
||||
<line x1="7" y1="12" x2="17" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function ImageIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function QuoteIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function BulletIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="9" y1="6" x2="20" y2="6" />
|
||||
<line x1="9" y1="12" x2="20" y2="12" />
|
||||
<line x1="9" y1="18" x2="20" y2="18" />
|
||||
<circle cx="4" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="4" cy="12" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="4" cy="18" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function NumberedIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="10" y1="6" x2="21" y2="6" />
|
||||
<line x1="10" y1="12" x2="21" y2="12" />
|
||||
<line x1="10" y1="18" x2="21" y2="18" />
|
||||
<path d="M4 6h1v4" />
|
||||
<path d="M4 10h2" />
|
||||
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
103
src/components/editor/frontmatter-panel.tsx
Normal file
103
src/components/editor/frontmatter-panel.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import type { FrontmatterFields } from "@/lib/frontmatter"
|
||||
import type { NoteStatus, Lang } from "@/lib/types"
|
||||
import { useLocale } from "@/hooks/use-locale"
|
||||
|
||||
interface Props {
|
||||
fields: FrontmatterFields
|
||||
onChange: (updated: FrontmatterFields) => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { value: NoteStatus; color: string }[] = [
|
||||
{ value: "seed", color: "var(--seed)" },
|
||||
{ value: "budding", color: "var(--budding)" },
|
||||
{ value: "evergreen", color: "var(--evergreen)" },
|
||||
]
|
||||
|
||||
export default function FrontmatterPanel({ fields, onChange }: Props) {
|
||||
const { t } = useLocale()
|
||||
const [local, setLocal] = useState(fields)
|
||||
|
||||
useEffect(() => { setLocal(fields) }, [fields.title, fields.status, fields.lang, fields.description])
|
||||
|
||||
function update(patch: Partial<FrontmatterFields>) {
|
||||
const next = { ...local, ...patch }
|
||||
setLocal(next)
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-border/40 bg-muted/30 px-4 py-2 flex items-center gap-4 flex-wrap text-[11px] font-mono select-none">
|
||||
|
||||
{/* Titel */}
|
||||
<input
|
||||
value={local.title}
|
||||
onChange={(e) => setLocal(l => ({ ...l, title: e.target.value }))}
|
||||
onBlur={(e) => { if (e.target.value !== fields.title) update({ title: e.target.value }) }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur() }}
|
||||
placeholder="Titel…"
|
||||
className="bg-transparent border-none outline-none text-foreground font-medium text-sm min-w-0 flex-1"
|
||||
style={{ minWidth: 120, maxWidth: 320 }}
|
||||
/>
|
||||
|
||||
<div className="w-px h-4 bg-border/40 shrink-0" />
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STATUS_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
title={t[opt.value as "seed" | "budding" | "evergreen"]}
|
||||
onClick={() => update({ status: opt.value })}
|
||||
className="flex items-center gap-1 px-1.5 py-0.5 rounded transition-all"
|
||||
style={{
|
||||
background: local.status === opt.value ? `oklch(from ${opt.color} l c h / 0.15)` : "transparent",
|
||||
color: local.status === opt.value ? opt.color : "var(--muted-fg)",
|
||||
border: `1px solid ${local.status === opt.value ? opt.color : "transparent"}`,
|
||||
opacity: local.status === opt.value ? 1 : 0.45,
|
||||
}}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: opt.color }} />
|
||||
<span className="capitalize">{t[opt.value as "seed" | "budding" | "evergreen"]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border/40 shrink-0" />
|
||||
|
||||
{/* Sprache */}
|
||||
<button
|
||||
onClick={() => update({ lang: local.lang === "de" ? "en" : "de" })}
|
||||
className="px-2 py-0.5 rounded border transition-colors"
|
||||
style={{
|
||||
borderColor: "var(--border)",
|
||||
color: "var(--muted-fg)",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
{local.lang.toUpperCase()}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-border/40 shrink-0" />
|
||||
|
||||
{/* Topic (schreibgeschützt — wird durch Verschieben geändert) */}
|
||||
<span className="text-muted-fg/40 truncate" style={{ maxWidth: 120 }}>
|
||||
{local.topic || "—"}
|
||||
</span>
|
||||
|
||||
{/* Description (expandiert rechts) */}
|
||||
<div className="flex-1 min-w-0 hidden lg:block">
|
||||
<input
|
||||
value={local.description}
|
||||
onChange={(e) => setLocal(l => ({ ...l, description: e.target.value }))}
|
||||
onBlur={(e) => { if (e.target.value !== fields.description) update({ description: e.target.value }) }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur() }}
|
||||
placeholder="Beschreibung…"
|
||||
className="w-full bg-transparent border-none outline-none text-muted-fg/60 italic text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
459
src/components/editor/settings-modal.tsx
Normal file
459
src/components/editor/settings-modal.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
type Section = "editor" | "appearance" | "connection" | "about"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function SettingsModal({ open, onClose }: Props) {
|
||||
const [section, setSection] = useState<Section>("editor")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
|
||||
document.addEventListener("keydown", onKey)
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: "oklch(from var(--background) l c h / 0.75)", backdropFilter: "blur(24px)", WebkitBackdropFilter: "blur(24px)" }}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div
|
||||
className="flex overflow-hidden rounded-2xl border border-border shadow-2xl w-full"
|
||||
style={{
|
||||
maxWidth: 860,
|
||||
height: "min(600px, calc(100vh - 32px))",
|
||||
background: "var(--surface)",
|
||||
}}
|
||||
>
|
||||
{/* ── Left nav ─────────────────────────────── */}
|
||||
<div
|
||||
className="flex flex-col py-6 shrink-0 border-r border-border"
|
||||
style={{ width: 220, background: "var(--muted)" }}
|
||||
>
|
||||
<div className="px-5 mb-4">
|
||||
<span className="text-[11px] font-mono text-muted-fg/50 uppercase tracking-widest">
|
||||
Einstellungen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{NAV.map((item) => (
|
||||
<NavBtn
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
active={section === item.id}
|
||||
onClick={() => setSection(item.id as Section)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="px-4 pb-1">
|
||||
<span className="text-[10px] font-mono text-muted-fg/25">noz-studio v0.1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right content ────────────────────────── */}
|
||||
<div className="flex-1 overflow-auto relative min-w-0">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 w-7 h-7 flex items-center justify-center rounded-lg text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors z-10"
|
||||
title="Schließen (Esc)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
<div className="px-8 py-7 max-w-lg pr-16">
|
||||
{section === "editor" && <EditorSection />}
|
||||
{section === "appearance" && <AppearanceSection />}
|
||||
{section === "connection" && <ConnectionSection />}
|
||||
{section === "about" && <AboutSection />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Nav ─────────────────────────────────────────── */
|
||||
|
||||
const NAV: { id: Section; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "editor", label: "Editor", icon: <EditorNavIcon /> },
|
||||
{ id: "appearance", label: "Darstellung", icon: <AppearanceNavIcon /> },
|
||||
{ id: "connection", label: "Verbindung", icon: <ConnectionNavIcon /> },
|
||||
{ id: "about", label: "Über noz", icon: <InfoNavIcon /> },
|
||||
]
|
||||
|
||||
function NavBtn({ label, icon, active, onClick }: {
|
||||
label: string; icon: React.ReactNode; active: boolean; onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 mx-2 rounded-lg text-sm transition-colors text-left ${
|
||||
active
|
||||
? "bg-surface-2 text-foreground"
|
||||
: "text-muted-fg hover:bg-surface hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 opacity-70">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Section components ──────────────────────────── */
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h2 className="text-base font-medium text-foreground mb-6">{children}</h2>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({ label, children }: { label?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-7">
|
||||
{label && (
|
||||
<div className="text-[10px] font-mono uppercase tracking-widest text-muted-fg/40 mb-3">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, description, children }: {
|
||||
label: string; description?: string; children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2.5 border-b border-border/40 last:border-0">
|
||||
<div>
|
||||
<div className="text-sm text-foreground">{label}</div>
|
||||
{description && <div className="text-xs text-muted-fg mt-0.5">{description}</div>}
|
||||
</div>
|
||||
<div className="shrink-0 ml-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative w-9 h-5 rounded-full transition-colors ${checked ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${
|
||||
checked ? "translate-x-4.5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ value, options, onChange }: {
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="text-xs font-mono bg-surface border border-border rounded-lg px-2.5 py-1.5 text-foreground outline-none focus:border-accent transition-colors cursor-pointer"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Editor Section ──────────────────────────────── */
|
||||
|
||||
function EditorSection() {
|
||||
const [font, setFont] = useState("jetbrains")
|
||||
const [lineNums, setLineNums] = useState(false)
|
||||
const [focusMode, setFocusMode] = useState(false)
|
||||
const [autoSave, setAutoSave] = useState(true)
|
||||
const [spellCheck, setSpellCheck] = useState(false)
|
||||
const [tabSize, setTabSize] = useState<2 | 4>(2)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Editor</SectionTitle>
|
||||
|
||||
<Group label="Schrift">
|
||||
<Row label="Schriftfamilie" description="Monospace-Schrift im Editor">
|
||||
<Select
|
||||
value={font}
|
||||
onChange={setFont}
|
||||
options={[
|
||||
{ value: "jetbrains", label: "JetBrains Mono" },
|
||||
{ value: "fira", label: "Fira Code" },
|
||||
{ value: "ibmplex", label: "IBM Plex Mono" },
|
||||
{ value: "sf", label: "SF Mono" },
|
||||
]}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Tabulator-Größe">
|
||||
<div className="flex rounded-lg overflow-hidden border border-border text-xs font-mono">
|
||||
{([2, 4] as const).map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setTabSize(n)}
|
||||
className={`px-3 py-1 transition-colors ${
|
||||
tabSize === n ? "bg-accent text-accent-fg" : "text-muted-fg hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group label="Verhalten">
|
||||
<Row label="Zeilennummern">
|
||||
<Toggle checked={lineNums} onChange={setLineNums} />
|
||||
</Row>
|
||||
<Row label="Fokus-Modus" description="Umgebende Zeilen abdunkeln">
|
||||
<Toggle checked={focusMode} onChange={setFocusMode} />
|
||||
</Row>
|
||||
<Row label="Auto-Speichern" description="Änderungen automatisch sichern">
|
||||
<Toggle checked={autoSave} onChange={setAutoSave} />
|
||||
</Row>
|
||||
<Row label="Rechtschreibprüfung">
|
||||
<Toggle checked={spellCheck} onChange={setSpellCheck} />
|
||||
</Row>
|
||||
</Group>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Appearance Section ──────────────────────────── */
|
||||
|
||||
const ACCENT_COLORS = [
|
||||
{ label: "Blau", value: "blue", oklch: "oklch(70% 0.18 253)" },
|
||||
{ label: "Lila", value: "purple", oklch: "oklch(65% 0.22 290)" },
|
||||
{ label: "Smaragd", value: "emerald", oklch: "oklch(62% 0.2 160)" },
|
||||
{ label: "Bernstein",value:"amber", oklch: "oklch(72% 0.18 75)" },
|
||||
{ label: "Rosa", value: "rose", oklch: "oklch(65% 0.22 10)" },
|
||||
]
|
||||
|
||||
function AppearanceSection() {
|
||||
const [theme, setTheme] = useState<"light" | "dark" | "system">(() => {
|
||||
if (typeof window === "undefined") return "system"
|
||||
const stored = localStorage.getItem("noz-studio-theme")
|
||||
return (stored as "light" | "dark") ?? "system"
|
||||
})
|
||||
const [accent, setAccent] = useState("blue")
|
||||
|
||||
const applyTheme = (t: "light" | "dark" | "system") => {
|
||||
setTheme(t)
|
||||
if (t === "dark") {
|
||||
document.documentElement.classList.add("dark")
|
||||
localStorage.setItem("noz-studio-theme", "dark")
|
||||
} else if (t === "light") {
|
||||
document.documentElement.classList.remove("dark")
|
||||
localStorage.setItem("noz-studio-theme", "light")
|
||||
} else {
|
||||
localStorage.removeItem("noz-studio-theme")
|
||||
const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches
|
||||
document.documentElement.classList.toggle("dark", prefersDark)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Darstellung</SectionTitle>
|
||||
|
||||
<Group label="Theme">
|
||||
<div className="flex rounded-xl overflow-hidden border border-border mt-1">
|
||||
{(["light", "dark", "system"] as const).map((t, i) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => applyTheme(t)}
|
||||
className={`flex-1 flex flex-col items-center gap-1.5 py-3 px-2 text-xs transition-colors border-r border-border last:border-0 ${
|
||||
theme === t ? "bg-surface-2 text-foreground" : "text-muted-fg hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{t === "light" ? <SunIcon /> : t === "dark" ? <MoonIcon /> : <SystemIcon />}
|
||||
<span>{t === "light" ? "Hell" : t === "dark" ? "Dunkel" : "System"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group label="Akzentfarbe">
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => setAccent(c.value)}
|
||||
title={c.label}
|
||||
className="relative w-7 h-7 rounded-full transition-transform hover:scale-110"
|
||||
style={{ background: c.oklch }}
|
||||
>
|
||||
{accent === c.value && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="w-2 h-2 rounded-full bg-white/80" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-fg mt-3">
|
||||
Akzentfarbe wird in einem späteren Update gespeichert.
|
||||
</p>
|
||||
</Group>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Connection Section ──────────────────────────── */
|
||||
|
||||
function ConnectionSection() {
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Verbindung</SectionTitle>
|
||||
|
||||
<Group label="Server">
|
||||
<Row label="URL" description="Deine noz-Instanz">
|
||||
<input
|
||||
placeholder="https://noz.example.com"
|
||||
className="text-xs font-mono bg-muted border border-border rounded-lg px-2.5 py-1.5 text-foreground outline-none focus:border-accent transition-colors w-44"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Auth-Methode" description="Konfiguriert in noosphere.toml">
|
||||
<span className="text-xs font-mono text-muted-fg bg-surface px-2.5 py-1 rounded-lg border border-border">
|
||||
token
|
||||
</span>
|
||||
</Row>
|
||||
</Group>
|
||||
|
||||
<Group label="Token">
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="••••••••••••••••"
|
||||
className="flex-1 text-xs font-mono bg-muted border border-border rounded-lg px-2.5 py-1.5 text-foreground outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="text-xs text-muted-fg hover:text-foreground transition-colors px-2.5 py-1.5 border border-border rounded-lg"
|
||||
>
|
||||
{showToken ? "Verbergen" : "Anzeigen"}
|
||||
</button>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group label="Berechtigungen (noosphere.toml)">
|
||||
<Row label="Schreiben (allow_push)">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-evergreen/15 text-evergreen">true</span>
|
||||
</Row>
|
||||
<Row label="Löschen (allow_delete)">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-draft/15 text-draft">false</span>
|
||||
</Row>
|
||||
<Row label="Rebuild (allow_rebuild)">
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded bg-evergreen/15 text-evergreen">true</span>
|
||||
</Row>
|
||||
<p className="text-xs text-muted-fg mt-3">
|
||||
Diese Werte kommen vom Server und können nur in der <code className="font-mono text-[11px]">noosphere.toml</code> geändert werden.
|
||||
</p>
|
||||
</Group>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── About Section ───────────────────────────────── */
|
||||
|
||||
function AboutSection() {
|
||||
const deps = [
|
||||
{ label: "Next.js", value: "16.2" },
|
||||
{ label: "CodeMirror", value: "6" },
|
||||
{ label: "marked", value: "15.0.12" },
|
||||
{ label: "KaTeX", value: "0.16" },
|
||||
{ label: "highlight.js", value: "11.11" },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionTitle>Über noz studio</SectionTitle>
|
||||
|
||||
<Group>
|
||||
<Row label="Version"><span className="text-xs font-mono text-muted-fg">v0.1.0</span></Row>
|
||||
<Row label="Editor-Engine"><span className="text-xs font-mono text-muted-fg">CodeMirror 6 (MIT)</span></Row>
|
||||
</Group>
|
||||
|
||||
<Group label="Abhängigkeiten">
|
||||
{deps.map((d) => (
|
||||
<Row key={d.label} label={d.label}>
|
||||
<span className="text-xs font-mono text-muted-fg">{d.value}</span>
|
||||
</Row>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
<Group label="Links">
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{[
|
||||
{ label: "Dokumentation", href: "#" },
|
||||
{ label: "Tastaturkürzel", href: "#" },
|
||||
{ label: "Changelog", href: "#" },
|
||||
].map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
className="text-xs px-3 py-1.5 rounded-lg border border-border text-muted-fg hover:text-foreground hover:bg-surface transition-colors"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Icons ───────────────────────────────────────── */
|
||||
|
||||
function CloseIcon() {
|
||||
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
}
|
||||
function EditorNavIcon() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
}
|
||||
function AppearanceNavIcon() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8a4 4 0 0 1 0 8"/></svg>
|
||||
}
|
||||
function ConnectionNavIcon() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
}
|
||||
function InfoNavIcon() {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
}
|
||||
function SunIcon() {
|
||||
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
}
|
||||
function MoonIcon() {
|
||||
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
}
|
||||
function SystemIcon() {
|
||||
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
}
|
||||
75
src/components/editor/settings-panel.tsx
Normal file
75
src/components/editor/settings-panel.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import ThemeToggle from "@/components/theme-toggle"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenModal: () => void
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ open, onClose, onOpenModal }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
|
||||
const onOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener("keydown", onKey)
|
||||
const t = setTimeout(() => document.addEventListener("mousedown", onOutside), 50)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey)
|
||||
clearTimeout(t)
|
||||
document.removeEventListener("mousedown", onOutside)
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-50 border border-border rounded-xl shadow-xl overflow-hidden"
|
||||
style={{
|
||||
top: 52,
|
||||
right: 8,
|
||||
width: 240,
|
||||
background: "var(--surface)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
}}
|
||||
>
|
||||
{/* Quick row: Theme */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-xs text-muted-fg">Theme</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Quick row: Font size placeholder */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<span className="text-xs text-muted-fg">Schriftgröße</span>
|
||||
<span className="text-xs font-mono text-muted-fg/60">14px</span>
|
||||
</div>
|
||||
|
||||
{/* Open full settings */}
|
||||
<button
|
||||
onClick={() => { onClose(); onOpenModal() }}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-xs text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
<span>Alle Einstellungen</span>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChevronRightIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
90
src/components/editor/status-bar.tsx
Normal file
90
src/components/editor/status-bar.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client"
|
||||
|
||||
import { useLocale } from "@/hooks/use-locale"
|
||||
import type { NoteContent } from "@/lib/types"
|
||||
|
||||
interface Props {
|
||||
note: NoteContent
|
||||
wordCount: number
|
||||
saved: boolean
|
||||
isOnline?: boolean
|
||||
hasPending?: boolean
|
||||
onDraftToggle?: () => void
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
seed: "var(--seed)",
|
||||
budding: "var(--budding)",
|
||||
evergreen: "var(--evergreen)",
|
||||
}
|
||||
|
||||
export default function StatusBar({ note, wordCount, saved, isOnline = true, hasPending = false, onDraftToggle }: Props) {
|
||||
const { t } = useLocale()
|
||||
|
||||
const color = STATUS_COLOR[note.status] ?? "var(--muted-fg)"
|
||||
const label = t[note.status as "seed" | "budding" | "evergreen"] ?? note.status
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 h-8 border-t border-border shrink-0 bg-muted/60 text-[11px] font-mono select-none">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<span className="text-muted-fg">{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="border border-border/60 px-1.5 py-px rounded text-[10px] text-muted-fg/60" style={{ lineHeight: "1.4" }}>
|
||||
{note.lang.toUpperCase()}
|
||||
</div>
|
||||
|
||||
{note.draftStage && (
|
||||
note.draftStage === "draft"
|
||||
? (
|
||||
<div className="border px-1.5 py-px rounded text-[10px]"
|
||||
style={{ borderColor: "var(--draft)", color: "var(--draft)", lineHeight: "1.4" }}>
|
||||
{t.draft}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDraftToggle}
|
||||
title={note.draftStage === "in-review" ? t.live : t.inReview}
|
||||
className="border px-1.5 py-px rounded text-[10px] transition-opacity hover:opacity-70 cursor-pointer"
|
||||
style={{ borderColor: "var(--draft)", color: "var(--draft)", lineHeight: "1.4" }}
|
||||
>
|
||||
{note.draftStage === "in-review" ? t.inReview : t.live}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{!note.draftStage && onDraftToggle && (
|
||||
<button
|
||||
onClick={onDraftToggle}
|
||||
title={t.inReview}
|
||||
className="border px-1.5 py-px rounded text-[10px] transition-opacity opacity-30 hover:opacity-60 cursor-pointer"
|
||||
style={{ borderColor: "var(--muted-fg)", color: "var(--muted-fg)", lineHeight: "1.4" }}
|
||||
>
|
||||
{t.live}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<span className="text-muted-fg/50">{wordCount} {t.words}</span>
|
||||
|
||||
{!isOnline && (
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono px-2 py-0.5 rounded" style={{ color: "var(--seed)", background: "oklch(from var(--seed) l c h / 0.08)" }}>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: "var(--seed)" }} />
|
||||
<span>offline{hasPending ? " — sync ausstehend" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1.5 transition-colors duration-300 ${saved && !hasPending ? "text-muted-fg/40" : "text-draft"}`}>
|
||||
{saved && !hasPending ? <CheckIcon /> : <UnsavedDot />}
|
||||
<span>{saved && !hasPending ? t.saved : hasPending ? "wird synchronisiert…" : t.unsaved}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
}
|
||||
function UnsavedDot() {
|
||||
return <div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: "var(--draft)" }} />
|
||||
}
|
||||
121
src/components/editor/toolbar.tsx
Normal file
121
src/components/editor/toolbar.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import type { EditorEngine } from "./engine"
|
||||
|
||||
export type ViewMode = "raw" | "split" | "rendered"
|
||||
|
||||
interface Props {
|
||||
engineRef: React.RefObject<EditorEngine | null>
|
||||
}
|
||||
|
||||
export default function FormatButtons({ engineRef }: Props) {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const moreRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const wrap = (before: string, after: string) => {
|
||||
engineRef.current?.wrapSelection(before, after)
|
||||
setMoreOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (moreRef.current && !moreRef.current.contains(e.target as Node)) setMoreOpen(false)
|
||||
}
|
||||
const t = setTimeout(() => document.addEventListener("mousedown", handler), 50)
|
||||
return () => { clearTimeout(t); document.removeEventListener("mousedown", handler) }
|
||||
}, [moreOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TBtn onClick={() => wrap("**", "**")} title="Fett (⌘B)"><BoldIcon /></TBtn>
|
||||
<TBtn onClick={() => wrap("*", "*")} title="Kursiv (⌘I)"><ItalicIcon /></TBtn>
|
||||
<TBtn onClick={() => wrap("~~", "~~")} title="Durchgestrichen"><StrikeIcon /></TBtn>
|
||||
<Sep />
|
||||
<TBtn onClick={() => wrap("\n# ", "")} title="H1"><span className="text-[10px] font-bold font-mono">H1</span></TBtn>
|
||||
<TBtn onClick={() => wrap("\n## ", "")} title="H2"><span className="text-[10px] font-bold font-mono">H2</span></TBtn>
|
||||
<TBtn onClick={() => wrap("\n### ","")} title="H3"><span className="text-[10px] font-bold font-mono">H3</span></TBtn>
|
||||
<Sep />
|
||||
<TBtn onClick={() => wrap("[", "](url)")} title="Link"><LinkIcon /></TBtn>
|
||||
<TBtn onClick={() => wrap("`", "`")} title="Inline Code"><CodeIcon /></TBtn>
|
||||
<Sep />
|
||||
|
||||
{/* ··· More-dropdown */}
|
||||
<div ref={moreRef} className="relative">
|
||||
<TBtn onClick={() => setMoreOpen((v) => !v)} title="Mehr einfügen" active={moreOpen}>
|
||||
<MoreIcon />
|
||||
</TBtn>
|
||||
|
||||
{moreOpen && (
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 z-50 py-1 rounded-xl border border-border shadow-xl min-w-[176px]"
|
||||
style={{ background: "var(--surface)", backdropFilter: "blur(16px)" }}
|
||||
>
|
||||
<DItem onClick={() => wrap("")} icon={<ImgIcon />} label="Bild" />
|
||||
<DItem onClick={() => wrap("\n```\n", "\n```")} icon={<CBlockIcon />} label="Code-Block" />
|
||||
<DItem onClick={() => wrap("\n> ", "")} icon={<QuoteIcon />} label="Zitat" />
|
||||
<DItem onClick={() => wrap("\n- ", "")} icon={<BulletIcon />} label="Liste" />
|
||||
<DItem onClick={() => wrap("\n1. ", "")} icon={<NumIcon />} label="Numm. Liste" />
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<DItem onClick={() => wrap("$", "$")} icon={<MathIcon />} label="Inline-Formel" />
|
||||
<DItem onClick={() => wrap("\n$$\n", "\n$$")} icon={<BMathIcon />} label="Block-Formel" />
|
||||
<DItem onClick={() => wrap("[[", "]]")} icon={<WikiIcon />} label="Wikilink" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────── */
|
||||
|
||||
function TBtn({
|
||||
children, onClick, title, active,
|
||||
}: { children: React.ReactNode; onClick: () => void; title?: string; active?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded transition-colors shrink-0 ${
|
||||
active
|
||||
? "text-foreground bg-surface-2"
|
||||
: "text-muted-fg hover:text-foreground hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DItem({ onClick, icon, label }: { onClick: () => void; icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors text-left"
|
||||
>
|
||||
<span className="shrink-0 opacity-70">{icon}</span>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <div className="w-px h-4 bg-border mx-0.5 shrink-0" />
|
||||
}
|
||||
|
||||
/* ── Icons ──────────────────────────────────────── */
|
||||
function BoldIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg> }
|
||||
function ItalicIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg> }
|
||||
function StrikeIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><path d="M16 6C16 6 14.5 4 12 4C9.5 4 7 5.5 7 8C7 10.5 9.5 11 12 12"/><path d="M8 18C8 18 9.5 20 12 20C14.5 20 17 18.5 17 16C17 14 15.5 13 12 12"/></svg> }
|
||||
function LinkIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> }
|
||||
function CodeIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> }
|
||||
function MoreIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="5" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg> }
|
||||
function ImgIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> }
|
||||
function CBlockIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="18" rx="2"/><polyline points="8 9 4 12 8 15"/><polyline points="16 9 20 12 16 15"/></svg> }
|
||||
function QuoteIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg> }
|
||||
function BulletIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1" fill="currentColor" stroke="none"/></svg> }
|
||||
function NumIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg> }
|
||||
function MathIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19 8 5"/><path d="M20 19 16 5"/><path d="M3 12h18"/></svg> }
|
||||
function BMathIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg> }
|
||||
function WikiIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="5" height="12" rx="1"/><rect x="17" y="6" width="5" height="12" rx="1"/><line x1="7" y1="12" x2="17" y2="12"/></svg> }
|
||||
334
src/components/preview/preview.tsx
Normal file
334
src/components/preview/preview.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useRef, useEffect, useState } from "react"
|
||||
import { Marked, type Tokens } from "marked"
|
||||
import katex from "katex"
|
||||
import hljs from "highlight.js"
|
||||
|
||||
/* ── KaTeX extensions ────────────────────────────── */
|
||||
|
||||
const blockMathExtension = {
|
||||
name: "blockMath",
|
||||
level: "block" as const,
|
||||
start: (src: string) => src.indexOf("$$"),
|
||||
tokenizer(src: string) {
|
||||
const match = /^\$\$([\s\S]+?)\$\$/.exec(src)
|
||||
if (match) return { type: "blockMath", raw: match[0], text: match[1].trim() }
|
||||
return undefined
|
||||
},
|
||||
renderer(token: { text: string }) {
|
||||
try {
|
||||
return `<div class="math-block">${katex.renderToString(token.text, {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
output: "html",
|
||||
})}</div>\n`
|
||||
} catch {
|
||||
return `<div class="math-block math-error">$$${token.text}$$</div>\n`
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const inlineMathExtension = {
|
||||
name: "inlineMath",
|
||||
level: "inline" as const,
|
||||
start: (src: string) => {
|
||||
const i = src.indexOf("$")
|
||||
return i === -1 ? undefined : i
|
||||
},
|
||||
tokenizer(src: string) {
|
||||
const match = /^\$(?!\$)([^$\n]+?)\$/.exec(src)
|
||||
if (match) return { type: "inlineMath", raw: match[0], text: match[1].trim() }
|
||||
return undefined
|
||||
},
|
||||
renderer(token: { text: string }) {
|
||||
try {
|
||||
return katex.renderToString(token.text, {
|
||||
displayMode: false,
|
||||
throwOnError: false,
|
||||
output: "html",
|
||||
})
|
||||
} catch {
|
||||
return `<span class="math-error">$${token.text}$</span>`
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/* ── Wikilink extension ──────────────────────────── */
|
||||
|
||||
const wikilinkExtension = {
|
||||
name: "wikilink",
|
||||
level: "inline" as const,
|
||||
start: (src: string) => src.indexOf("[["),
|
||||
tokenizer(src: string) {
|
||||
// [[target#fragment|text]] — target and fragment are both optional
|
||||
const match = /^\[\[([^\]|#]*?)(?:#([^\]|]*?))?(?:\|([^\]]+?))?\]\]/.exec(src)
|
||||
if (!match) return undefined
|
||||
const target = match[1].trim()
|
||||
const fragment = match[2]?.trim() ?? ""
|
||||
const fallback = target + (fragment ? `#${fragment}` : "")
|
||||
return {
|
||||
type: "wikilink",
|
||||
raw: match[0],
|
||||
target,
|
||||
fragment,
|
||||
text: (match[3] ?? fallback).trim(),
|
||||
}
|
||||
},
|
||||
renderer(token: { target: string; fragment: string; text: string }) {
|
||||
const escaped = token.text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
const target = token.target.replace(/"/g, """)
|
||||
const frag = token.fragment.replace(/"/g, """)
|
||||
return `<span class="preview-wikilink" data-wikilink="${target}" data-fragment="${frag}">${escaped}</span>`
|
||||
},
|
||||
}
|
||||
|
||||
/* ── Marked instance (isolated, not global) ──────── */
|
||||
|
||||
const md = new Marked()
|
||||
|
||||
md.use({
|
||||
renderer: {
|
||||
code(token: Tokens.Code): string {
|
||||
const lang = (token.lang ?? "").split(/\s/)[0]
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
const highlighted = hljs.highlight(token.text, {
|
||||
language: lang,
|
||||
ignoreIllegals: true,
|
||||
}).value
|
||||
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>\n`
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return `<pre><code class="hljs">${hljs.highlightAuto(token.text).value}</code></pre>\n`
|
||||
},
|
||||
heading(token: Tokens.Heading): string {
|
||||
const raw = token.text.replace(/<[^>]*>/g, "")
|
||||
const id = raw.toLowerCase().replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-")
|
||||
return `<h${token.depth} id="${id}">${token.text}</h${token.depth}>\n`
|
||||
},
|
||||
link(token: Tokens.Link): string {
|
||||
const href = token.href ?? ""
|
||||
const isExt = /^https?:\/\//.test(href)
|
||||
const titleAttr = token.title ? ` title="${token.title.replace(/"/g, """)}"` : ""
|
||||
const extAttrs = isExt ? ` target="_blank" rel="noopener noreferrer"` : ""
|
||||
return `<a href="${href}"${titleAttr}${extAttrs}>${token.text}</a>`
|
||||
},
|
||||
},
|
||||
extensions: [blockMathExtension, inlineMathExtension, wikilinkExtension],
|
||||
})
|
||||
|
||||
/* ── Frontmatter-Parser ────────────────────────────── */
|
||||
|
||||
interface FmData { title: string; description?: string; status?: string; lang?: string; topic?: string; draft?: boolean }
|
||||
|
||||
function parseFm(markdown: string): { fm: FmData | null; body: string } {
|
||||
if (!markdown.startsWith("---\n")) return { fm: null, body: markdown }
|
||||
const end = markdown.indexOf("\n---\n", 4)
|
||||
if (end === -1) return { fm: null, body: markdown }
|
||||
|
||||
const lines = markdown.slice(4, end).split("\n")
|
||||
const body = markdown.slice(end + 5)
|
||||
const fm: FmData = { title: "" }
|
||||
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (!m) continue
|
||||
const [, key, rawVal] = m
|
||||
const val = rawVal.replace(/^["']|["']$/g, "").trim()
|
||||
switch (key) {
|
||||
case "title": fm.title = val; break
|
||||
case "description": fm.description = val; break
|
||||
case "status": fm.status = val; break
|
||||
case "lang": fm.lang = val; break
|
||||
case "topic": fm.topic = val; break
|
||||
case "draft": fm.draft = rawVal.trim() === "true"; break
|
||||
}
|
||||
}
|
||||
return { fm: fm.title ? fm : null, body }
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
seed: "var(--seed)",
|
||||
budding: "var(--budding)",
|
||||
evergreen: "var(--evergreen)",
|
||||
}
|
||||
|
||||
/* ── Component ────────────────────────────────────── */
|
||||
|
||||
interface NoteRef { slug: string; title: string }
|
||||
|
||||
interface Props {
|
||||
content: string
|
||||
className?: string
|
||||
notes?: NoteRef[]
|
||||
onSelectNote?: (slug: string) => void
|
||||
}
|
||||
|
||||
export default function Preview({ content, className, notes, onSelectNote }: Props) {
|
||||
const { fm, body } = useMemo(() => parseFm(content), [content])
|
||||
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
const result = md.parse(body)
|
||||
return typeof result === "string" ? result : ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}, [body])
|
||||
|
||||
// Refs so the event listener never goes stale
|
||||
const proseRef = useRef<HTMLDivElement>(null)
|
||||
const notesRef = useRef<NoteRef[]>(notes ?? [])
|
||||
const selectRef = useRef(onSelectNote)
|
||||
const pendingFragmentRef = useRef<string | null>(null)
|
||||
useEffect(() => { notesRef.current = notes ?? [] }, [notes])
|
||||
useEffect(() => { selectRef.current = onSelectNote }, [onSelectNote])
|
||||
|
||||
// After HTML re-renders, scroll to any pending fragment (cross-note navigation)
|
||||
useEffect(() => {
|
||||
if (!pendingFragmentRef.current || !proseRef.current) return
|
||||
const frag = pendingFragmentRef.current
|
||||
pendingFragmentRef.current = null
|
||||
const target = proseRef.current.querySelector(`#${CSS.escape(frag)}`)
|
||||
if (target) setTimeout(() => target.scrollIntoView({ behavior: "smooth", block: "start" }), 50)
|
||||
}, [html])
|
||||
|
||||
// Toast for unresolvable wikilinks
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
clearTimeout(toastTimer.current)
|
||||
toastTimer.current = setTimeout(() => setToast(null), 4000)
|
||||
}
|
||||
|
||||
// Mark broken wikilinks visually after render
|
||||
useEffect(() => {
|
||||
const el = proseRef.current
|
||||
if (!el) return
|
||||
el.querySelectorAll<HTMLElement>("[data-wikilink]").forEach(span => {
|
||||
const raw = span.dataset.wikilink ?? ""
|
||||
const lower = raw.toLowerCase().trim()
|
||||
const slug = raw.toLowerCase().normalize("NFD").replace(/\p{M}/gu, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
||||
const list = notesRef.current
|
||||
const found = !!(
|
||||
list.find(n => n.title.toLowerCase().trim() === lower) ??
|
||||
list.find(n => n.slug.toLowerCase() === lower) ??
|
||||
list.find(n => (n.slug.split("/").pop() ?? "") === slug)
|
||||
)
|
||||
span.dataset.broken = found ? "false" : "true"
|
||||
})
|
||||
}, [html, notes])
|
||||
|
||||
useEffect(() => {
|
||||
const el = proseRef.current
|
||||
if (!el) return
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement
|
||||
|
||||
const wikiSpan = t.closest<HTMLElement>("[data-wikilink]")
|
||||
if (wikiSpan) {
|
||||
const raw = wikiSpan.dataset.wikilink ?? ""
|
||||
const fragment = wikiSpan.dataset.fragment ?? ""
|
||||
|
||||
// In-page anchor: [[#abschnitt]]
|
||||
if (!raw && fragment) {
|
||||
const anchor = proseRef.current?.querySelector(`#${CSS.escape(fragment)}`)
|
||||
if (anchor) anchor.scrollIntoView({ behavior: "smooth", block: "start" })
|
||||
else showToast(`„#${fragment}" — Abschnitt nicht gefunden`)
|
||||
return
|
||||
}
|
||||
|
||||
const lower = raw.toLowerCase().trim()
|
||||
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
||||
const list = notesRef.current
|
||||
const note =
|
||||
list.find(n => n.title.toLowerCase().trim() === lower) ??
|
||||
list.find(n => n.slug.toLowerCase() === lower) ??
|
||||
list.find(n => (n.slug.split("/").pop() ?? "") === slug) ??
|
||||
list.find(n => (n.slug.split("/").pop() ?? "").includes(slug.slice(0, 20)))
|
||||
if (note) {
|
||||
if (fragment) pendingFragmentRef.current = fragment
|
||||
selectRef.current?.(note.slug)
|
||||
} else {
|
||||
showToast(`„${raw.length > 48 ? raw.slice(0, 48) + "…" : raw}" — Notiz nicht gefunden`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = t.closest<HTMLAnchorElement>("a[href]")
|
||||
if (anchor && /^https?:\/\//.test(anchor.href)) {
|
||||
e.preventDefault()
|
||||
window.open(anchor.href, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener("click", handleClick)
|
||||
return () => el.removeEventListener("click", handleClick)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-auto ${className ?? ""}`}>
|
||||
{toast && (
|
||||
<div className="preview-toast">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-7 py-10">
|
||||
|
||||
{/* Frontmatter-Header */}
|
||||
{fm && (
|
||||
<div className="mb-8 pb-6 border-b border-border/40">
|
||||
{fm.topic && (
|
||||
<div className="text-[10px] font-mono text-muted-fg/40 uppercase tracking-widest mb-2">
|
||||
{fm.topic}
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
className="text-2xl font-semibold mb-2 leading-snug"
|
||||
style={{ fontFamily: "var(--font-newsreader, Georgia, serif)", fontStyle: "italic" }}
|
||||
>
|
||||
{fm.title}
|
||||
</h1>
|
||||
{fm.description && (
|
||||
<p className="text-sm text-muted-fg mb-3 leading-relaxed">{fm.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{fm.status && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono px-2 py-0.5 rounded-full border"
|
||||
style={{
|
||||
borderColor: STATUS_COLOR[fm.status] ?? "var(--border)",
|
||||
color: STATUS_COLOR[fm.status] ?? "var(--muted-fg)",
|
||||
background: `oklch(from ${STATUS_COLOR[fm.status] ?? "var(--muted-fg)"} l c h / 0.08)`,
|
||||
}}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: STATUS_COLOR[fm.status] ?? "var(--muted-fg)" }} />
|
||||
{fm.status}
|
||||
</span>
|
||||
)}
|
||||
{fm.draft && (
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full border" style={{ borderColor: "var(--draft)", color: "var(--draft)", background: "oklch(from var(--draft) l c h / 0.08)" }}>
|
||||
in review
|
||||
</span>
|
||||
)}
|
||||
{fm.lang && (
|
||||
<span className="text-[10px] font-mono text-muted-fg/40 border border-border/40 px-2 py-0.5 rounded-full">
|
||||
{fm.lang.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={proseRef}
|
||||
className="preview-prose"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
355
src/components/search/command-palette.tsx
Normal file
355
src/components/search/command-palette.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react"
|
||||
import type { NoteMeta, Topic } from "@/lib/types"
|
||||
import { useLocale } from "@/hooks/use-locale"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSelectNote: (slug: string) => void
|
||||
notes: NoteMeta[]
|
||||
drafts: NoteMeta[]
|
||||
topics: Topic[]
|
||||
}
|
||||
|
||||
type ParsedQuery =
|
||||
| { scope: "all"; text: string }
|
||||
| { scope: "web"; text: string }
|
||||
| { scope: "draft"; text: string }
|
||||
| { scope: "topic"; topic: string; text: string }
|
||||
|
||||
function parseQuery(raw: string): ParsedQuery {
|
||||
const m = /^@(\S+)(?:\s+(.*))?$/.exec(raw.trim())
|
||||
if (!m) return { scope: "all", text: raw.trim() }
|
||||
const token = m[1].toLowerCase()
|
||||
const text = (m[2] ?? "").trim()
|
||||
if (token === "web" || token === "online") return { scope: "web", text }
|
||||
if (token === "draft" || token === "drafts") return { scope: "draft", text }
|
||||
return { scope: "topic", topic: token, text }
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
seed: "var(--seed)",
|
||||
budding: "var(--budding)",
|
||||
evergreen: "var(--evergreen)",
|
||||
draft: "var(--draft)",
|
||||
}
|
||||
|
||||
export default function CommandPalette({ open, onClose, onSelectNote, notes, drafts, topics }: Props) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [active, setActive] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useLocale()
|
||||
|
||||
const parsed = useMemo(() => parseQuery(query), [query])
|
||||
|
||||
// Filter results
|
||||
const results: NoteMeta[] = useMemo(() => {
|
||||
const q = parsed.text.toLowerCase()
|
||||
let pool: NoteMeta[]
|
||||
if (parsed.scope === "draft") {
|
||||
pool = drafts
|
||||
} else if (parsed.scope === "topic") {
|
||||
pool = notes.filter((n) => n.slug.startsWith(parsed.topic + "/"))
|
||||
} else {
|
||||
pool = [...notes, ...drafts]
|
||||
}
|
||||
if (!q) return pool.slice(0, 8)
|
||||
return pool
|
||||
.filter((n) =>
|
||||
n.title.toLowerCase().includes(q) ||
|
||||
n.slug.toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, 8)
|
||||
}, [parsed, notes, drafts])
|
||||
|
||||
const showWebItem = parsed.scope === "web" && parsed.text.length > 0
|
||||
const showWebHint = parsed.scope === "all" && parsed.text.length > 1
|
||||
|
||||
const totalItems = results.length + (showWebItem ? 1 : 0) + (showWebHint ? 1 : 0)
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("")
|
||||
setActive(0)
|
||||
setTimeout(() => inputRef.current?.focus(), 10)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Keep active in bounds
|
||||
useEffect(() => { setActive(0) }, [query])
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") { onClose(); return }
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setActive((v) => Math.min(v + 1, totalItems - 1)) }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); setActive((v) => Math.max(v - 1, 0)) }
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleSelect(active)
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handler)
|
||||
return () => document.removeEventListener("keydown", handler)
|
||||
}, [open, active, totalItems, results, showWebItem, showWebHint, parsed.text])
|
||||
|
||||
function handleSelect(idx: number) {
|
||||
if (showWebItem && idx === 0) {
|
||||
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(parsed.text)}`, "_blank")
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
const noteIdx = showWebItem ? idx - 1 : idx
|
||||
if (noteIdx < results.length) {
|
||||
onSelectNote(results[noteIdx].slug)
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
// showWebHint is last
|
||||
if (showWebHint) {
|
||||
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(parsed.text)}`, "_blank")
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const scopeLabel: Record<string, string> = {
|
||||
web: "@web",
|
||||
draft: "@draft",
|
||||
topic: parsed.scope === "topic" ? `@${(parsed as {topic: string}).topic}` : "",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center"
|
||||
style={{
|
||||
paddingTop: "13vh",
|
||||
background: "oklch(from var(--background) l c h / 0.45)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
}}
|
||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div
|
||||
className="w-full mx-4 rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
maxWidth: 520,
|
||||
background: "oklch(from var(--surface) l c h / 0.72)",
|
||||
backdropFilter: "blur(24px)",
|
||||
WebkitBackdropFilter: "blur(24px)",
|
||||
border: "1px solid oklch(from var(--border) l c h / 0.6)",
|
||||
boxShadow: "0 24px 64px oklch(0% 0 0 / 0.28), 0 4px 16px oklch(0% 0 0 / 0.12)",
|
||||
}}
|
||||
>
|
||||
{/* Input row */}
|
||||
<div className="flex items-center gap-2 px-3.5 py-2.5" style={{ borderBottom: "1px solid oklch(from var(--border) l c h / 0.4)" }}>
|
||||
<SearchIcon className="shrink-0 text-muted-fg/40" />
|
||||
|
||||
{/* Scope badge */}
|
||||
{parsed.scope !== "all" && (
|
||||
<span
|
||||
className="shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded-full border"
|
||||
style={{
|
||||
color: parsed.scope === "web" ? "var(--accent)" : parsed.scope === "draft" ? "var(--draft)" : "var(--budding)",
|
||||
borderColor: parsed.scope === "web" ? "oklch(from var(--accent) l c h / 0.4)" : parsed.scope === "draft" ? "oklch(from var(--draft) l c h / 0.4)" : "oklch(from var(--budding) l c h / 0.4)",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
{scopeLabel[parsed.scope] ?? parsed.scope}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={parsed.scope !== "all" ? t.searchTermPlaceholder : t.searchPlaceholder}
|
||||
className="flex-1 bg-transparent text-[13px] text-foreground placeholder:text-muted-fg/35 outline-none"
|
||||
/>
|
||||
|
||||
<kbd className="shrink-0 text-[10px] font-mono text-muted-fg/25 border rounded px-1.5 py-0.5"
|
||||
style={{ borderColor: "oklch(from var(--border) l c h / 0.4)" }}>
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="overflow-auto" style={{ maxHeight: 320 }}>
|
||||
{/* Scope suggestions when user typed @ with nothing after */}
|
||||
{query === "@" && (
|
||||
<div className="px-3.5 py-3">
|
||||
<p className="text-[10px] font-mono text-muted-fg/40 mb-2 uppercase tracking-widest">{t.scopeLabel}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{topics.map((t) => (
|
||||
<button
|
||||
key={t.slug}
|
||||
onClick={() => setQuery(`@${t.slug} `)}
|
||||
className="text-[11px] font-mono px-2 py-0.5 rounded-full border border-budding/30 text-budding/60 hover:text-budding hover:border-budding/60 transition-colors"
|
||||
>
|
||||
@{t.slug}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setQuery("@web ")}
|
||||
className="text-[11px] font-mono px-2 py-0.5 rounded-full border border-accent/30 text-accent/60 hover:text-accent hover:border-accent/60 transition-colors"
|
||||
>
|
||||
@web
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setQuery("@draft ")}
|
||||
className="text-[11px] font-mono px-2 py-0.5 rounded-full border border-draft/30 text-draft/60 hover:text-draft hover:border-draft/60 transition-colors"
|
||||
>
|
||||
@draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web-first item */}
|
||||
{showWebItem && (
|
||||
<ResultRow
|
||||
icon={<ExternalIcon />}
|
||||
title={t.webSearch(parsed.text)}
|
||||
sub={t.webSearchSub}
|
||||
active={active === 0}
|
||||
onClick={() => handleSelect(0)}
|
||||
accent
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Note results */}
|
||||
{results.map((note, i) => {
|
||||
const idx = showWebItem ? i + 1 : i
|
||||
const color = STATUS_COLOR[note.status] ?? "var(--muted-fg)"
|
||||
return (
|
||||
<ResultRow
|
||||
key={note.slug}
|
||||
icon={<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: color }} />}
|
||||
title={note.title}
|
||||
sub={note.slug}
|
||||
active={active === idx}
|
||||
onClick={() => handleSelect(idx)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Web fallback at bottom */}
|
||||
{showWebHint && (
|
||||
<ResultRow
|
||||
icon={<ExternalIcon />}
|
||||
title={t.webSearch(parsed.text)}
|
||||
sub={t.webSearchHint}
|
||||
active={active === results.length}
|
||||
onClick={() => handleSelect(results.length)}
|
||||
muted
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{results.length === 0 && !showWebItem && query.length > 0 && query !== "@" && (
|
||||
<div className="px-4 py-6 text-center text-xs text-muted-fg/35 font-mono">
|
||||
{t.noResults}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial hint */}
|
||||
{query.length === 0 && (
|
||||
<div className="px-3.5 py-2.5">
|
||||
<p className="text-[10px] font-mono text-muted-fg/35 mb-1.5 uppercase tracking-widest">{t.recentlyOpened}</p>
|
||||
{[...notes, ...drafts].slice(0, 5).map((note, i) => {
|
||||
const color = STATUS_COLOR[note.status] ?? "var(--muted-fg)"
|
||||
return (
|
||||
<ResultRow
|
||||
key={note.slug}
|
||||
icon={<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: color }} />}
|
||||
title={note.title}
|
||||
sub={note.slug}
|
||||
active={active === i}
|
||||
onClick={() => { onSelectNote(note.slug); onClose() }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 px-3.5 py-1.5" style={{ borderTop: "1px solid oklch(from var(--border) l c h / 0.3)" }}>
|
||||
<HintKey keys={["↑", "↓"]} label={t.navigate} />
|
||||
<HintKey keys={["↵"]} label={t.open} />
|
||||
<HintKey keys={["Esc"]} label={t.close} />
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] font-mono text-muted-fg/20">⌘K</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Sub-components ─────────────────────────────── */
|
||||
|
||||
function ResultRow({ icon, title, sub, active, onClick, accent, muted }: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
sub?: string
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
accent?: boolean
|
||||
muted?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 text-left transition-colors ${
|
||||
active ? "bg-surface-2/60" : "hover:bg-surface-2/30"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 flex items-center justify-center w-3.5">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-[13px] truncate ${
|
||||
accent ? "text-accent" : muted ? "text-muted-fg/60" : "text-foreground"
|
||||
}`}>
|
||||
{title}
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="text-[10px] font-mono text-muted-fg/40 truncate">{sub}</div>
|
||||
)}
|
||||
</div>
|
||||
{active && <ArrowRightIcon />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HintKey({ keys, label }: { keys: string[]; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((k) => (
|
||||
<kbd
|
||||
key={k}
|
||||
className="text-[10px] font-mono text-muted-fg/30 border rounded px-1"
|
||||
style={{ borderColor: "oklch(from var(--border) l c h / 0.35)" }}
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
<span className="text-[10px] text-muted-fg/25 ml-0.5">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Icons ──────────────────────────────────────── */
|
||||
function SearchIcon({ className }: { className?: string }) {
|
||||
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
}
|
||||
function ExternalIcon() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-accent"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
}
|
||||
function ArrowRightIcon() {
|
||||
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-muted-fg/30 shrink-0"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
}
|
||||
746
src/components/sidebar/folder-tree.tsx
Normal file
746
src/components/sidebar/folder-tree.tsx
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import type { Topic, NoteMeta } from "@/lib/types"
|
||||
import { useLocale } from "@/hooks/use-locale"
|
||||
import type { Translations } from "@/i18n"
|
||||
|
||||
interface Props {
|
||||
topics: Topic[]
|
||||
drafts: NoteMeta[]
|
||||
activeSlug: string
|
||||
onSelectNote: (slug: string) => void
|
||||
onRenameNote?: (slug: string, newTitle: string) => void
|
||||
onMoveNote?: (slug: string, newTopic: string) => void
|
||||
onDeleteNote?: (slug: string) => void
|
||||
onNewNote?: (topic: string | undefined, title: string) => void
|
||||
onNewFolder?: (parentSlug: string | null, name: string) => void
|
||||
onRenameFolder?: (slug: string, newSlug: string) => void
|
||||
onDeleteFolder?: (slug: string) => void
|
||||
onSelectDiagram?: (folderSlug: string) => void
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
seed: "var(--seed)",
|
||||
budding: "var(--budding)",
|
||||
evergreen: "var(--evergreen)",
|
||||
draft: "var(--draft)",
|
||||
}
|
||||
|
||||
type CtxTarget =
|
||||
| { kind: "folder"; topic: Topic }
|
||||
| { kind: "note"; note: NoteMeta }
|
||||
|
||||
interface CtxMenu {
|
||||
target: CtxTarget
|
||||
x: number
|
||||
y: number
|
||||
screen: "main" | "move"
|
||||
}
|
||||
|
||||
interface Creating {
|
||||
type: "note" | "folder"
|
||||
parentSlug: string | null
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
FolderTree
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
export default function FolderTree({
|
||||
topics, drafts, activeSlug,
|
||||
onSelectNote, onRenameNote, onMoveNote, onDeleteNote,
|
||||
onNewNote, onNewFolder, onRenameFolder, onDeleteFolder,
|
||||
onSelectDiagram,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||||
// Alle Vorfahren-Ordner des aktiven Slugs aufklappen
|
||||
const initial = new Set<string>()
|
||||
if (topics[0]?.slug) initial.add(topics[0].slug)
|
||||
return initial
|
||||
})
|
||||
const [renamingSlug, setRenamingSlug] = useState<string | null>(null)
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||||
const [creating, setCreating] = useState<Creating | null>(null)
|
||||
// drag state
|
||||
const [dragging, setDragging] = useState<NoteMeta | null>(null)
|
||||
|
||||
// Alle Vorfahren-Ordner des aktiven Slugs automatisch aufklappen
|
||||
useEffect(() => {
|
||||
if (!activeSlug) return
|
||||
const parts = activeSlug.split("/")
|
||||
if (parts.length <= 1) return
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev)
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
next.add(parts.slice(0, i).join("/"))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [activeSlug])
|
||||
|
||||
const closeCtx = useCallback(() => setCtxMenu(null), [])
|
||||
|
||||
const toggle = (slug: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(slug)) next.delete(slug); else next.add(slug)
|
||||
return next
|
||||
})
|
||||
|
||||
const openContext = (e: React.MouseEvent, target: CtxTarget) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setCtxMenu({ target, x: e.clientX, y: e.clientY, screen: "main" })
|
||||
}
|
||||
|
||||
const handleRenameNote = (slug: string, newTitle: string) => {
|
||||
setRenamingSlug(null)
|
||||
if (newTitle.trim()) onRenameNote?.(slug, newTitle.trim())
|
||||
}
|
||||
|
||||
const handleMoveNote = (noteSlug: string, newTopic: string) => {
|
||||
onMoveNote?.(noteSlug, newTopic)
|
||||
closeCtx()
|
||||
}
|
||||
|
||||
const handleNewFolder = (parentSlug: string | null, name: string) => {
|
||||
setCreating(null)
|
||||
if (name.trim()) {
|
||||
onNewFolder?.(parentSlug, name.trim())
|
||||
if (parentSlug) setExpanded((p) => new Set([...p, parentSlug]))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (note: NoteMeta, targetTopic: string) => {
|
||||
if (note.topic !== targetTopic) onMoveNote?.(note.slug, targetTopic)
|
||||
setDragging(null)
|
||||
}
|
||||
|
||||
const allTopicSlugs = collectSlugs(topics)
|
||||
const { t } = useLocale()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full select-none">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border shrink-0">
|
||||
<span className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">{t.noosphere}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToolBtn title={t.newNote} onClick={() => setCreating({ type: "note", parentSlug: null })}><FileNewIcon /></ToolBtn>
|
||||
<ToolBtn title={t.newFolder} onClick={() => setCreating({ type: "folder", parentSlug: null })}><FolderNewIcon /></ToolBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 overflow-auto py-1 min-h-0">
|
||||
{creating?.parentSlug === null && (
|
||||
<CreationRow
|
||||
type={creating.type}
|
||||
onCommit={(name) => creating.type === "folder" ? handleNewFolder(null, name) : (onNewNote?.(undefined, name), setCreating(null))}
|
||||
onCancel={() => setCreating(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{topics.map((topic) => (
|
||||
<TopicRow
|
||||
key={topic.slug}
|
||||
topic={topic}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
activeSlug={activeSlug}
|
||||
renamingSlug={renamingSlug}
|
||||
creating={creating}
|
||||
dragging={dragging}
|
||||
onToggle={toggle}
|
||||
onOpenContext={openContext}
|
||||
onSelectNote={onSelectNote}
|
||||
onRenameNoteStart={(s) => setRenamingSlug(s)}
|
||||
onRenameNoteCommit={handleRenameNote}
|
||||
onCreatingChange={setCreating}
|
||||
onNewFolder={handleNewFolder}
|
||||
onNewNote={(t, title) => onNewNote?.(t, title)}
|
||||
onDragStart={setDragging}
|
||||
onDragEnd={() => setDragging(null)}
|
||||
onDrop={handleDrop}
|
||||
onExpandFolder={(slug) => setExpanded((p) => new Set([...p, slug]))}
|
||||
onSelectDiagram={onSelectDiagram}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Drafts section */}
|
||||
{drafts.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-border/50">
|
||||
<button
|
||||
onClick={() => toggle("_drafts")}
|
||||
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-surface/50 transition-colors group"
|
||||
>
|
||||
<ChevronIcon open={expanded.has("_drafts")} />
|
||||
<DraftIcon />
|
||||
<span className="text-[11px] font-mono uppercase tracking-wider flex-1 truncate group-hover:text-foreground transition-colors" style={{ color: "var(--draft)" }}>
|
||||
{t.drafts}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-fg/40 shrink-0">{drafts.length}</span>
|
||||
</button>
|
||||
|
||||
{expanded.has("_drafts") && drafts.map((note) => (
|
||||
<NoteItem
|
||||
key={note.slug}
|
||||
note={note}
|
||||
depth={1}
|
||||
active={note.slug === activeSlug}
|
||||
isDraft
|
||||
renaming={renamingSlug === note.slug}
|
||||
isDragging={dragging?.slug === note.slug}
|
||||
onClick={() => onSelectNote(note.slug)}
|
||||
onContextMenu={(e) => openContext(e, { kind: "note", note })}
|
||||
onRenameStart={() => setRenamingSlug(note.slug)}
|
||||
onRenameCommit={(t) => handleRenameNote(note.slug, t)}
|
||||
onDragStart={() => setDragging(note)}
|
||||
onDragEnd={() => setDragging(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<ContextMenu
|
||||
menu={ctxMenu}
|
||||
allTopicSlugs={allTopicSlugs}
|
||||
t={t}
|
||||
onClose={closeCtx}
|
||||
onRenameFolder={(slug) => { setRenamingSlug(slug); closeCtx() }}
|
||||
onNewNoteInFolder={(slug) => {
|
||||
setExpanded((p) => new Set([...p, slug]))
|
||||
setCreating({ type: "note", parentSlug: slug })
|
||||
closeCtx()
|
||||
}}
|
||||
onNewSubfolder={(slug) => {
|
||||
setExpanded((p) => new Set([...p, slug]))
|
||||
setCreating({ type: "folder", parentSlug: slug })
|
||||
closeCtx()
|
||||
}}
|
||||
onDeleteFolder={(slug) => { onDeleteFolder?.(slug); closeCtx() }}
|
||||
onRenameNote={(slug) => { setRenamingSlug(slug); closeCtx() }}
|
||||
onMoveNote={handleMoveNote}
|
||||
onDeleteNote={(slug) => { onDeleteNote?.(slug); closeCtx() }}
|
||||
onSwitchScreen={(s) => setCtxMenu((m) => m ? { ...m, screen: s } : null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
TopicRow (rekursiv)
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
function TopicRow({
|
||||
topic, depth, expanded, activeSlug, renamingSlug, creating, dragging,
|
||||
onToggle, onOpenContext, onSelectNote,
|
||||
onRenameNoteStart, onRenameNoteCommit,
|
||||
onCreatingChange, onNewFolder, onNewNote,
|
||||
onDragStart, onDragEnd, onDrop, onExpandFolder, onSelectDiagram,
|
||||
}: {
|
||||
topic: Topic
|
||||
depth: number
|
||||
expanded: Set<string>
|
||||
activeSlug: string
|
||||
renamingSlug: string | null
|
||||
creating: Creating | null
|
||||
dragging: NoteMeta | null
|
||||
onToggle: (slug: string) => void
|
||||
onOpenContext: (e: React.MouseEvent, target: CtxTarget) => void
|
||||
onSelectNote: (slug: string) => void
|
||||
onRenameNoteStart: (slug: string) => void
|
||||
onRenameNoteCommit: (slug: string, title: string) => void
|
||||
onCreatingChange: (c: Creating | null) => void
|
||||
onNewFolder: (parentSlug: string | null, name: string) => void
|
||||
onNewNote: (topic: string, title: string) => void
|
||||
onDragStart: (note: NoteMeta) => void
|
||||
onDragEnd: () => void
|
||||
onDrop: (note: NoteMeta, targetTopic: string) => void
|
||||
onExpandFolder: (slug: string) => void
|
||||
onSelectDiagram?: (folderSlug: string) => void
|
||||
}) {
|
||||
const isOpen = expanded.has(topic.slug)
|
||||
const isRenamingFolder = renamingSlug === topic.slug
|
||||
const indent = depth * 12
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const expandTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const canDrop = dragging && dragging.topic !== topic.slug
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (!canDrop) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
setIsDragOver(true)
|
||||
// auto-expand nach 600 ms
|
||||
if (!expanded.has(topic.slug)) {
|
||||
clearTimeout(expandTimer.current)
|
||||
expandTimer.current = setTimeout(() => onExpandFolder(topic.slug), 600)
|
||||
}
|
||||
}
|
||||
const handleDragLeave = () => {
|
||||
setIsDragOver(false)
|
||||
clearTimeout(expandTimer.current)
|
||||
}
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
clearTimeout(expandTimer.current)
|
||||
if (dragging && dragging.topic !== topic.slug) onDrop(dragging, topic.slug)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Folder header */}
|
||||
<div
|
||||
className={`group relative flex items-center gap-1.5 py-1.5 transition-all ${
|
||||
isDragOver
|
||||
? "bg-accent/10 ring-1 ring-inset ring-accent/30 rounded-md mx-1"
|
||||
: "hover:bg-surface/50"
|
||||
}`}
|
||||
style={{ paddingLeft: `${10 + indent}px`, paddingRight: "6px" }}
|
||||
onContextMenu={(e) => onOpenContext(e, { kind: "folder", topic })}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<button onClick={() => onToggle(topic.slug)} className="flex items-center gap-1.5 flex-1 min-w-0 text-left">
|
||||
<ChevronIcon open={isOpen} />
|
||||
{!isRenamingFolder && (
|
||||
<span className="text-[11px] font-mono text-muted-fg uppercase tracking-wider group-hover:text-foreground transition-colors flex-1 truncate">
|
||||
{topic.slug.split("/").pop()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRenamingFolder ? (
|
||||
<FolderRenameInput
|
||||
current={topic.slug.split("/").pop() ?? topic.slug}
|
||||
onCommit={(newName) => {
|
||||
const parent = topic.slug.includes("/") ? topic.slug.slice(0, topic.slug.lastIndexOf("/")) : null
|
||||
onRenameNoteCommit(topic.slug, parent ? `${parent}/${newName}` : newName)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-fg/40 shrink-0 group-hover:hidden">{topic.notes.length}</span>
|
||||
<div className="hidden group-hover:flex items-center gap-0.5">
|
||||
{onSelectDiagram && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSelectDiagram(topic.slug) }}
|
||||
title="Diagramm"
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-muted-fg/50 hover:text-foreground hover:bg-surface-2 transition-colors shrink-0"
|
||||
>
|
||||
<DiagramIcon />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => onOpenContext(e, { kind: "folder", topic })}
|
||||
title="Optionen"
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-muted-fg/50 hover:text-foreground hover:bg-surface-2 transition-colors shrink-0"
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isOpen && (
|
||||
<div>
|
||||
{creating?.parentSlug === topic.slug && (
|
||||
<CreationRow
|
||||
type={creating.type}
|
||||
depth={depth + 1}
|
||||
onCommit={(name) => creating.type === "folder" ? onNewFolder(topic.slug, name) : (onNewNote(topic.slug, name), onCreatingChange(null))}
|
||||
onCancel={() => onCreatingChange(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{topic.subTopics?.map((sub) => (
|
||||
<TopicRow
|
||||
key={sub.slug}
|
||||
topic={sub}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
activeSlug={activeSlug}
|
||||
renamingSlug={renamingSlug}
|
||||
creating={creating}
|
||||
dragging={dragging}
|
||||
onToggle={onToggle}
|
||||
onOpenContext={onOpenContext}
|
||||
onSelectNote={onSelectNote}
|
||||
onRenameNoteStart={onRenameNoteStart}
|
||||
onRenameNoteCommit={onRenameNoteCommit}
|
||||
onCreatingChange={onCreatingChange}
|
||||
onNewFolder={onNewFolder}
|
||||
onNewNote={onNewNote}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDrop={onDrop}
|
||||
onExpandFolder={onExpandFolder}
|
||||
onSelectDiagram={onSelectDiagram}
|
||||
/>
|
||||
))}
|
||||
|
||||
{topic.notes.map((note) => (
|
||||
<NoteItem
|
||||
key={note.slug}
|
||||
note={note}
|
||||
depth={depth + 1}
|
||||
active={note.slug === activeSlug}
|
||||
renaming={renamingSlug === note.slug}
|
||||
isDragging={dragging?.slug === note.slug}
|
||||
onClick={() => onSelectNote(note.slug)}
|
||||
onContextMenu={(e) => onOpenContext(e, { kind: "note", note })}
|
||||
onRenameStart={() => onRenameNoteStart(note.slug)}
|
||||
onRenameCommit={(t) => onRenameNoteCommit(note.slug, t)}
|
||||
onDragStart={() => onDragStart(note)}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* _diagram.toml — einmal pro Ordner */}
|
||||
{onSelectDiagram && (
|
||||
<button
|
||||
onClick={() => onSelectDiagram(topic.slug)}
|
||||
className="w-full flex items-center gap-1.5 text-left transition-colors hover:bg-surface/50 group"
|
||||
style={{ paddingLeft: `${22 + indent}px`, paddingRight: "8px", paddingTop: "3px", paddingBottom: "3px" }}
|
||||
>
|
||||
<DiagramIcon />
|
||||
<span className="text-[10px] font-mono text-muted-fg/30 group-hover:text-muted-fg/60 transition-colors">
|
||||
_diagram
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
NoteItem
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
function NoteItem({
|
||||
note, depth = 1, active, isDraft, renaming, isDragging,
|
||||
onClick, onContextMenu, onRenameStart, onRenameCommit,
|
||||
onDragStart, onDragEnd,
|
||||
}: {
|
||||
note: NoteMeta
|
||||
depth?: number
|
||||
active: boolean
|
||||
isDraft?: boolean
|
||||
renaming: boolean
|
||||
isDragging: boolean
|
||||
onClick: () => void
|
||||
onContextMenu: (e: React.MouseEvent) => void
|
||||
onRenameStart: () => void
|
||||
onRenameCommit: (title: string) => void
|
||||
onDragStart: () => void
|
||||
onDragEnd: () => void
|
||||
}) {
|
||||
const color = isDraft ? "var(--draft)" : STATUS_COLOR[note.status] ?? "var(--muted-fg)"
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const indent = depth * 12
|
||||
|
||||
useEffect(() => { if (renaming) inputRef.current?.select() }, [renaming])
|
||||
|
||||
if (renaming) {
|
||||
return (
|
||||
<div className="relative" style={{ paddingLeft: `${8 + indent}px`, paddingRight: "8px", paddingTop: "4px", paddingBottom: "4px" }}>
|
||||
{active && <ActiveBar />}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
defaultValue={note.title}
|
||||
className="flex-1 min-w-0 text-xs bg-surface border border-accent/50 rounded px-1.5 py-0.5 text-foreground outline-none focus:border-accent"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onRenameCommit(e.currentTarget.value)
|
||||
if (e.key === "Escape") onRenameCommit(note.title)
|
||||
}}
|
||||
onBlur={(e) => onRenameCommit(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", note.slug)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
onDragStart()
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
className={`relative group flex items-center pr-1 py-1 transition-all cursor-grab active:cursor-grabbing ${
|
||||
isDragging
|
||||
? "opacity-40 scale-95"
|
||||
: active
|
||||
? "bg-surface text-foreground"
|
||||
: "text-muted-fg hover:bg-surface/50 hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + indent}px` }}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{active && !isDragging && <ActiveBar />}
|
||||
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left text-xs">
|
||||
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: color, opacity: active ? 1 : 0.7 }} />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onContextMenu(e) }}
|
||||
title="Optionen"
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-muted-fg/0 group-hover:text-muted-fg hover:!text-foreground hover:bg-surface-2 transition-colors shrink-0"
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
ContextMenu
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
function ContextMenu({
|
||||
menu, allTopicSlugs, t, onClose,
|
||||
onRenameFolder, onNewNoteInFolder, onNewSubfolder, onDeleteFolder,
|
||||
onRenameNote, onMoveNote, onDeleteNote, onSwitchScreen,
|
||||
}: {
|
||||
menu: CtxMenu
|
||||
allTopicSlugs: string[]
|
||||
t: Translations
|
||||
onClose: () => void
|
||||
onRenameFolder: (slug: string) => void
|
||||
onNewNoteInFolder: (slug: string) => void
|
||||
onNewSubfolder: (slug: string) => void
|
||||
onDeleteFolder: (slug: string) => void
|
||||
onRenameNote: (slug: string) => void
|
||||
onMoveNote: (noteSlug: string, newTopic: string) => void
|
||||
onDeleteNote: (slug: string) => void
|
||||
onSwitchScreen: (s: "main" | "move") => void
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState({ x: menu.x, y: menu.y })
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() }
|
||||
const key = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }
|
||||
const t = setTimeout(() => { document.addEventListener("mousedown", down); document.addEventListener("keydown", key) }, 10)
|
||||
return () => { clearTimeout(t); document.removeEventListener("mousedown", down); document.removeEventListener("keydown", key) }
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
const { offsetWidth: w, offsetHeight: h } = ref.current
|
||||
setPos({
|
||||
x: Math.min(menu.x, window.innerWidth - w - 8),
|
||||
y: Math.min(menu.y, window.innerHeight - h - 8),
|
||||
})
|
||||
}, [menu.x, menu.y, menu.screen])
|
||||
|
||||
const noteSlug = menu.target.kind === "note" ? menu.target.note.slug : null
|
||||
const folderSlug = menu.target.kind === "folder" ? menu.target.topic.slug : null
|
||||
const noteTopic = menu.target.kind === "note" ? menu.target.note.topic : null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[200] py-1.5 rounded-xl border border-border shadow-2xl"
|
||||
style={{
|
||||
left: pos.x, top: pos.y,
|
||||
minWidth: 210,
|
||||
background: "oklch(from var(--surface) l c h / 0.95)",
|
||||
backdropFilter: "blur(20px)",
|
||||
WebkitBackdropFilter: "blur(20px)",
|
||||
}}
|
||||
>
|
||||
{menu.screen === "main" ? (
|
||||
<>
|
||||
{menu.target.kind === "folder" && folderSlug && (
|
||||
<>
|
||||
<MItem icon={<FileNewIcon />} label={t.newNote} onClick={() => onNewNoteInFolder(folderSlug)} />
|
||||
<MItem icon={<FolderNewIcon />} label={t.subfolder} onClick={() => onNewSubfolder(folderSlug)} />
|
||||
<MSep />
|
||||
<MItem icon={<PenIcon />} label={t.rename} onClick={() => onRenameFolder(folderSlug)} />
|
||||
<MSep />
|
||||
<MItem icon={<TrashIcon />} label={t.delete} onClick={() => onDeleteFolder(folderSlug)} danger />
|
||||
</>
|
||||
)}
|
||||
{menu.target.kind === "note" && noteSlug && (
|
||||
<>
|
||||
<MItem icon={<PenIcon />} label={t.rename} onClick={() => onRenameNote(noteSlug)} />
|
||||
<MItem icon={<MoveIcon />} label={t.moveTo} onClick={() => onSwitchScreen("move")} hasArrow />
|
||||
<MSep />
|
||||
<MItem icon={<TrashIcon />} label={t.delete} onClick={() => onDeleteNote(noteSlug)} danger />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Move-Screen */
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3.5 py-2">
|
||||
<button onClick={() => onSwitchScreen("main")} className="text-muted-fg/50 hover:text-foreground transition-colors">
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<span className="text-[11px] font-mono text-muted-fg/60 uppercase tracking-widest">{t.moveTo}</span>
|
||||
</div>
|
||||
<MSep />
|
||||
{allTopicSlugs.filter((s) => s !== noteTopic).map((s) => (
|
||||
<MItem key={s} icon={<FolderIcon />} label={s} onClick={() => noteSlug && onMoveNote(noteSlug, s)} />
|
||||
))}
|
||||
{allTopicSlugs.filter((s) => s !== noteTopic).length === 0 && (
|
||||
<p className="px-3.5 py-2.5 text-xs text-muted-fg/40 font-mono">{t.noOtherFolders}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
CreationRow
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
function CreationRow({ type, depth = 0, onCommit, onCancel }: {
|
||||
type: "note" | "folder"; depth?: number; onCommit: (name: string) => void; onCancel: () => void
|
||||
}) {
|
||||
const indent = depth * 12
|
||||
const { t } = useLocale()
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5" style={{ paddingLeft: `${10 + indent}px`, paddingRight: "8px" }}>
|
||||
{type === "folder" ? <FolderIcon className="text-muted-fg/50 shrink-0" /> : <FileIcon className="text-muted-fg/50 shrink-0" />}
|
||||
<input
|
||||
autoFocus
|
||||
placeholder={type === "folder" ? t.folderName : t.noteName}
|
||||
className="flex-1 min-w-0 text-xs bg-surface border border-accent/50 rounded px-1.5 py-0.5 text-foreground outline-none focus:border-accent"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommit(e.currentTarget.value)
|
||||
if (e.key === "Escape") onCancel()
|
||||
}}
|
||||
onBlur={(e) => { if (e.currentTarget.value.trim()) onCommit(e.currentTarget.value); else onCancel() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderRenameInput({ current, onCommit }: { current: string; onCommit: (name: string) => void }) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
defaultValue={current}
|
||||
className="flex-1 min-w-0 text-[11px] font-mono bg-surface border border-accent/50 rounded px-1.5 py-0.5 text-foreground outline-none focus:border-accent uppercase tracking-wider"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCommit(e.currentTarget.value)
|
||||
if (e.key === "Escape") onCommit(current)
|
||||
}}
|
||||
onBlur={(e) => onCommit(e.target.value || current)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Helpers
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
function collectSlugs(topics: Topic[]): string[] {
|
||||
const r: string[] = []
|
||||
for (const t of topics) { r.push(t.slug); if (t.subTopics) r.push(...collectSlugs(t.subTopics)) }
|
||||
return r
|
||||
}
|
||||
|
||||
function ActiveBar() {
|
||||
return <div className="absolute left-0 top-0 bottom-0 w-0.5 rounded-r" style={{ backgroundColor: "var(--accent)" }} />
|
||||
}
|
||||
|
||||
/* ── Kontextmenü-Elemente ───────────────────────────── */
|
||||
|
||||
function MItem({ icon, label, onClick, danger, hasArrow }: {
|
||||
icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean; hasArrow?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-3 px-3.5 py-2 text-[13px] text-left transition-colors ${
|
||||
danger
|
||||
? "text-draft/80 hover:text-draft hover:bg-draft/10"
|
||||
: "text-muted-fg hover:text-foreground hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 opacity-60">{icon}</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
{hasArrow && <ChevronRightSmIcon />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function MSep() { return <div className="my-1 h-px bg-border/50" /> }
|
||||
|
||||
function ToolBtn({ onClick, title, children }: { onClick: () => void; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<button onClick={onClick} title={title} className="w-5 h-5 flex items-center justify-center rounded text-muted-fg hover:text-foreground hover:bg-surface transition-colors">
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
Icons
|
||||
══════════════════════════════════════════════════════ */
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`shrink-0 text-muted-fg/40 transition-transform duration-150 ${open ? "rotate-90" : ""}`}>
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function ChevronLeftIcon() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
}
|
||||
function ChevronRightSmIcon() {
|
||||
return <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="text-muted-fg/40 shrink-0"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
}
|
||||
function PenIcon() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
}
|
||||
function TrashIcon() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||||
}
|
||||
function MoveIcon() {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>
|
||||
}
|
||||
function MoreIcon() {
|
||||
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="5" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||
}
|
||||
function DraftIcon() {
|
||||
return <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--draft)" }}><circle cx="12" cy="12" r="10" strokeDasharray="4 2"/></svg>
|
||||
}
|
||||
function FileNewIcon() {
|
||||
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||
}
|
||||
function FolderNewIcon() {
|
||||
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>
|
||||
}
|
||||
function FolderIcon({ className }: { className?: string }) {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
}
|
||||
function FileIcon({ className }: { className?: string }) {
|
||||
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
}
|
||||
function DiagramIcon() {
|
||||
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||||
}
|
||||
43
src/components/star-field.tsx
Normal file
43
src/components/star-field.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
function makeStars(count: number, minOp: number, maxOp: number, minSize: number, maxSize: number): string {
|
||||
return Array.from({ length: count }, () => {
|
||||
const x = (Math.random() * 100).toFixed(2)
|
||||
const y = (Math.random() * 100).toFixed(2)
|
||||
const op = (Math.random() * (maxOp - minOp) + minOp).toFixed(2)
|
||||
const size = minSize + Math.random() * (maxSize - minSize)
|
||||
return `${x}vw ${y}vh 0 ${size.toFixed(1)}px rgba(255,255,255,${op})`
|
||||
}).join(",")
|
||||
}
|
||||
|
||||
export default function StarField() {
|
||||
const dimRef = useRef<HTMLDivElement>(null) // many dim stars
|
||||
const brightRef = useRef<HTMLDivElement>(null) // few bright stars
|
||||
|
||||
useEffect(() => {
|
||||
// Layer 1: 280 small dim stars
|
||||
if (dimRef.current) dimRef.current.style.boxShadow = makeStars(280, 0.25, 0.6, 1, 1.2)
|
||||
// Layer 2: 40 larger bright stars
|
||||
if (brightRef.current) brightRef.current.style.boxShadow = makeStars(40, 0.6, 1.0, 1.5, 2.5)
|
||||
}, [])
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderRadius: "50%",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none" aria-hidden>
|
||||
{/* Dim star layer */}
|
||||
<div ref={dimRef} style={base} />
|
||||
{/* Bright star layer — subtle pulse */}
|
||||
<div ref={brightRef} style={{ ...base, animation: "star-pulse 6s ease-in-out infinite" }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/components/theme-toggle.tsx
Normal file
65
src/components/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
const THEME_KEY = "noz-studio-theme"
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setIsDark(document.documentElement.classList.contains("dark"))
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const next = !isDark
|
||||
setIsDark(next)
|
||||
if (next) {
|
||||
document.documentElement.classList.add("dark")
|
||||
localStorage.setItem(THEME_KEY, "dark")
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark")
|
||||
localStorage.setItem(THEME_KEY, "light")
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="w-7 h-7" />
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={isDark ? "Light Mode" : "Dark Mode"}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-muted-fg hover:text-foreground hover:bg-surface transition-colors"
|
||||
>
|
||||
{isDark ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
29
src/hooks/use-locale.ts
Normal file
29
src/hooks/use-locale.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { getTranslations } from "@/i18n"
|
||||
import type { Translations, Locale } from "@/i18n"
|
||||
|
||||
const STORAGE_KEY = "noz-studio-locale"
|
||||
|
||||
export interface UseLocaleReturn {
|
||||
locale: Locale
|
||||
setLocale: (l: Locale) => void
|
||||
t: Translations
|
||||
}
|
||||
|
||||
export function useLocale(): UseLocaleReturn {
|
||||
const [locale, setLocaleState] = useState<Locale>("de")
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null
|
||||
if (stored === "de" || stored === "en") setLocaleState(stored)
|
||||
}, [])
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l)
|
||||
localStorage.setItem(STORAGE_KEY, l)
|
||||
}, [])
|
||||
|
||||
return { locale, setLocale, t: getTranslations(locale) }
|
||||
}
|
||||
472
src/hooks/use-notes.ts
Normal file
472
src/hooks/use-notes.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import {
|
||||
getNotes, getNote, putNote,
|
||||
createNote as createNoteApi,
|
||||
deleteNote as deleteNoteApi,
|
||||
patchNote as patchNoteApi,
|
||||
promoteNote as promoteNoteApi,
|
||||
NozApiError,
|
||||
type NoteListItem,
|
||||
} from "@/lib/api"
|
||||
import { FIXTURE_NOTES, FIXTURE_DRAFTS, FIXTURE_TOPICS } from "@/lib/dev-fixtures"
|
||||
import type { NoteContent, NoteMeta, Topic, NoteStatus, Lang } from "@/lib/types"
|
||||
|
||||
const DEV = process.env.NEXT_PUBLIC_DEV_FIXTURES === "true"
|
||||
|
||||
export interface UseNotesReturn {
|
||||
topics: Topic[]
|
||||
drafts: NoteMeta[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
activeNote: NoteContent | null
|
||||
noteLoading: boolean
|
||||
isOnline: boolean
|
||||
hasPending: boolean
|
||||
selectNote: (slug: string) => void
|
||||
saveNote: (slug: string, markdown: string) => Promise<"saved" | "queued">
|
||||
createNote: (title: string, status?: NoteStatus, lang?: Lang) => Promise<string>
|
||||
deleteNote: (slug: string) => Promise<void>
|
||||
renameNote: (slug: string, newTitle: string) => Promise<void>
|
||||
toggleDraft: (slug: string) => Promise<void>
|
||||
promoteNote: (slug: string, newTopic: string) => Promise<void>
|
||||
updateActiveNote: (slug: string, updates: Partial<NoteContent>) => void
|
||||
refreshNotes: () => void
|
||||
}
|
||||
|
||||
export function useNotes(): UseNotesReturn {
|
||||
const [topics, setTopics] = useState<Topic[]> (() => DEV ? FIXTURE_TOPICS : [])
|
||||
const [drafts, setDrafts] = useState<NoteMeta[]> (() => DEV ? FIXTURE_DRAFTS : [])
|
||||
const [loading, setLoading] = useState(!DEV)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeNote, setActiveNote] = useState<NoteContent | null>(() => DEV ? FIXTURE_NOTES[0] : null)
|
||||
const [noteLoading, setNoteLoading] = useState(false)
|
||||
|
||||
const cacheRef = useRef<Map<string, NoteContent>>(new Map())
|
||||
const topicsRef = useRef<Topic[]> (DEV ? FIXTURE_TOPICS : [])
|
||||
const draftsRef = useRef<NoteMeta[]> (DEV ? FIXTURE_DRAFTS : [])
|
||||
const loadingSlugRef = useRef<string>("")
|
||||
// Offline queue: slug → latest markdown to save when back online
|
||||
const pendingRef = useRef<Map<string, string>>(new Map())
|
||||
const [isOnline, setIsOnline] = useState(() => typeof navigator !== "undefined" ? navigator.onLine : true)
|
||||
const [hasPending, setHasPending] = useState(false)
|
||||
|
||||
useEffect(() => { topicsRef.current = topics }, [topics])
|
||||
useEffect(() => { draftsRef.current = drafts }, [drafts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!DEV) return
|
||||
;[...FIXTURE_NOTES, ...FIXTURE_DRAFTS].forEach(n => cacheRef.current.set(n.slug, n))
|
||||
}, [])
|
||||
|
||||
/* ── Offline queue flush ─────────────────────────────────────────── */
|
||||
|
||||
const flushPending = useCallback(async () => {
|
||||
const entries = [...pendingRef.current.entries()]
|
||||
if (entries.length === 0) return
|
||||
for (const [slug, markdown] of entries) {
|
||||
try {
|
||||
await putNote(slug, markdown)
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, content: markdown })
|
||||
pendingRef.current.delete(slug)
|
||||
} catch {
|
||||
break // Still offline — stop and wait for next online event
|
||||
}
|
||||
}
|
||||
setHasPending(pendingRef.current.size > 0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (DEV) return
|
||||
const handleOnline = () => { setIsOnline(true); flushPending() }
|
||||
const handleOffline = () => { setIsOnline(false) }
|
||||
window.addEventListener("online", handleOnline)
|
||||
window.addEventListener("offline", handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline)
|
||||
window.removeEventListener("offline", handleOffline)
|
||||
}
|
||||
}, [flushPending])
|
||||
|
||||
/* ── selectNote ─────────────────────────────────────────────────── */
|
||||
|
||||
const selectNote: (slug: string) => void = useCallback((slug: string) => {
|
||||
// Always update so a cached-note click cancels any running fetch
|
||||
loadingSlugRef.current = slug
|
||||
|
||||
if (DEV) {
|
||||
const note = cacheRef.current.get(slug)
|
||||
if (note) setActiveNote(note)
|
||||
return
|
||||
}
|
||||
if (cacheRef.current.has(slug)) {
|
||||
setNoteLoading(false) // cancel any previous loading spinner
|
||||
setActiveNote(cacheRef.current.get(slug)!)
|
||||
return
|
||||
}
|
||||
setNoteLoading(true)
|
||||
getNote(slug)
|
||||
.then(({ markdown }) => {
|
||||
// Discard if the user already navigated elsewhere
|
||||
if (loadingSlugRef.current !== slug) return
|
||||
|
||||
// Recurse into subTopics to find meta — flatMap(t => t.notes) only
|
||||
// covers the direct notes of each root topic, missing subtopic notes.
|
||||
const meta =
|
||||
findNoteMeta(topicsRef.current, slug) ??
|
||||
draftsRef.current.find(n => n.slug === slug)
|
||||
|
||||
const note: NoteContent = {
|
||||
slug,
|
||||
title: meta?.title ?? slug,
|
||||
topic: meta?.topic ?? "",
|
||||
status: (meta?.status ?? "seed") as NoteStatus,
|
||||
lang: (meta?.lang ?? "de") as Lang,
|
||||
draft: meta?.draft,
|
||||
content: markdown,
|
||||
}
|
||||
cacheRef.current.set(slug, note)
|
||||
setActiveNote(note)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (loadingSlugRef.current !== slug) return
|
||||
if (e instanceof NozApiError && e.status === 404) {
|
||||
// Note was deleted externally — remove from local state, then refresh list
|
||||
cacheRef.current.delete(slug)
|
||||
setTopics(prev => prev
|
||||
.map(t => ({ ...t, notes: t.notes.filter(n => n.slug !== slug) }))
|
||||
.filter(t => t.notes.length > 0 || t.subTopics?.length)
|
||||
)
|
||||
setDrafts(prev => prev.filter(n => n.slug !== slug))
|
||||
setActiveNote(null)
|
||||
// Reload full list so sidebar reflects server state
|
||||
getNotes().then(items => {
|
||||
const { topics: newTopics, drafts: newDrafts } = buildTopicTree(items)
|
||||
setTopics(newTopics)
|
||||
setDrafts(newDrafts)
|
||||
topicsRef.current = newTopics
|
||||
draftsRef.current = newDrafts
|
||||
const first = flattenNotes(newTopics)[0] ?? newDrafts[0]
|
||||
if (first) selectNote(first.slug)
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
console.error("[useNotes] getNote:", e)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadingSlugRef.current === slug) setNoteLoading(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
/* ── saveNote ───────────────────────────────────────────────────── */
|
||||
|
||||
const saveNote = useCallback(async (slug: string, markdown: string): Promise<"saved" | "queued"> => {
|
||||
if (DEV) {
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, content: markdown })
|
||||
return "saved"
|
||||
}
|
||||
try {
|
||||
await putNote(slug, markdown)
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, content: markdown })
|
||||
// Clear from pending queue if it was there
|
||||
if (pendingRef.current.has(slug)) {
|
||||
pendingRef.current.delete(slug)
|
||||
setHasPending(pendingRef.current.size > 0)
|
||||
}
|
||||
return "saved"
|
||||
} catch (e) {
|
||||
if (e instanceof NozApiError && e.status === 0) {
|
||||
// Network error — queue for retry, always keep latest content
|
||||
pendingRef.current.set(slug, markdown)
|
||||
setHasPending(true)
|
||||
return "queued"
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── createNote ─────────────────────────────────────────────────── */
|
||||
|
||||
const createNote = useCallback(async (
|
||||
title: string,
|
||||
status: NoteStatus = "seed",
|
||||
lang: Lang = "de",
|
||||
): Promise<string> => {
|
||||
if (DEV) {
|
||||
const slug: string = `_drafts/${title.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}`
|
||||
const note: NoteContent = {
|
||||
slug, title, topic: "", status, lang,
|
||||
draftStage: "draft",
|
||||
content: `# ${title}\n\n`,
|
||||
}
|
||||
cacheRef.current.set(slug, note)
|
||||
const meta: NoteMeta = { slug, title, topic: "", status, lang, draftStage: "draft" }
|
||||
setDrafts(prev => [meta, ...prev])
|
||||
setActiveNote(note)
|
||||
return slug
|
||||
}
|
||||
const { slug } = await createNoteApi(title, undefined, status, lang)
|
||||
const note: NoteContent = {
|
||||
slug, title, topic: "", status, lang,
|
||||
draftStage: "draft",
|
||||
content: `---\ntitle: "${title}"\ndescription: ""\nstatus: ${status}\nlang: ${lang}\n---\n\n# ${title}\n\n`,
|
||||
}
|
||||
cacheRef.current.set(slug, note)
|
||||
const meta: NoteMeta = { slug, title, topic: "", status, lang, draftStage: "draft" }
|
||||
setDrafts(prev => [meta, ...prev])
|
||||
setActiveNote(note)
|
||||
return slug
|
||||
}, [])
|
||||
|
||||
/* ── deleteNote ─────────────────────────────────────────────────── */
|
||||
|
||||
const deleteNote = useCallback(async (slug: string) => {
|
||||
if (!DEV) await deleteNoteApi(slug)
|
||||
cacheRef.current.delete(slug)
|
||||
setTopics(prev => prev
|
||||
.map(t => ({ ...t, notes: t.notes.filter(n => n.slug !== slug) }))
|
||||
.filter(t => t.notes.length > 0)
|
||||
)
|
||||
setDrafts(prev => prev.filter(n => n.slug !== slug))
|
||||
setActiveNote(prev => {
|
||||
if (prev?.slug !== slug) return prev
|
||||
// Select first available note after deletion
|
||||
const allMeta = [
|
||||
...flattenNotes(topicsRef.current),
|
||||
...draftsRef.current,
|
||||
].filter(n => n.slug !== slug)
|
||||
const next = allMeta[0]
|
||||
if (!next) return null
|
||||
// Trigger content load (async, non-blocking)
|
||||
setTimeout(() => selectNote(next.slug), 0)
|
||||
return null
|
||||
})
|
||||
}, [selectNote])
|
||||
|
||||
/* ── renameNote ─────────────────────────────────────────────────── */
|
||||
|
||||
const renameNote = useCallback(async (slug: string, newTitle: string) => {
|
||||
const title = newTitle.trim()
|
||||
if (!title) return
|
||||
if (DEV) {
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, title })
|
||||
setTopics(prev => prev.map(t => ({
|
||||
...t,
|
||||
notes: t.notes.map(n => n.slug === slug ? { ...n, title } : n),
|
||||
})))
|
||||
setDrafts(prev => prev.map(n => n.slug === slug ? { ...n, title } : n))
|
||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
|
||||
return
|
||||
}
|
||||
await patchNoteApi(slug, { title })
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, title })
|
||||
setTopics(prev => prev.map(t => ({
|
||||
...t,
|
||||
notes: t.notes.map(n => n.slug === slug ? { ...n, title } : n),
|
||||
})))
|
||||
setDrafts(prev => prev.map(n => n.slug === slug ? { ...n, title } : n))
|
||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
|
||||
}, [])
|
||||
|
||||
/* ── toggleDraft ────────────────────────────────────────────────── */
|
||||
|
||||
const toggleDraft = useCallback(async (slug: string) => {
|
||||
const meta =
|
||||
topicsRef.current.flatMap(t => t.notes).find(n => n.slug === slug) ??
|
||||
draftsRef.current.find(n => n.slug === slug)
|
||||
if (!meta || meta.draftStage === "draft") return // can't toggle _drafts/ notes here
|
||||
|
||||
const nextDraft = !meta.draft
|
||||
const nextStage = nextDraft ? ("in-review" as const) : undefined
|
||||
const update = (n: NoteMeta) => n.slug === slug ? { ...n, draft: nextDraft, draftStage: nextStage } : n
|
||||
|
||||
if (!DEV) await patchNoteApi(slug, { draft: nextDraft })
|
||||
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, draft: nextDraft, draftStage: nextStage })
|
||||
setTopics(prev => prev.map(t => ({ ...t, notes: t.notes.map(update) })))
|
||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, draft: nextDraft, draftStage: nextStage } : prev)
|
||||
}, [])
|
||||
|
||||
/* ── promoteNote ─────────────────────────────────────────────── */
|
||||
|
||||
const promoteNote = useCallback(async (slug: string, newTopic: string) => {
|
||||
if (!DEV) {
|
||||
const { newSlug } = await promoteNoteApi(slug, newTopic)
|
||||
// Remove from drafts, reload full list so sidebar reflects server state
|
||||
cacheRef.current.delete(slug)
|
||||
setDrafts(prev => prev.filter(n => n.slug !== slug))
|
||||
setActiveNote(prev => prev?.slug === slug ? null : prev)
|
||||
await new Promise<void>((resolve) => {
|
||||
getNotes().then(items => {
|
||||
const { topics: newTopics, drafts: newDrafts } = buildTopicTree(items)
|
||||
setTopics(newTopics)
|
||||
setDrafts(newDrafts)
|
||||
topicsRef.current = newTopics
|
||||
draftsRef.current = newDrafts
|
||||
selectNote(newSlug)
|
||||
resolve()
|
||||
}).catch(() => resolve())
|
||||
})
|
||||
} else {
|
||||
// DEV mode: simple local move
|
||||
const meta = draftsRef.current.find(n => n.slug === slug)
|
||||
if (!meta) return
|
||||
const newSlug = `${newTopic}/${slug.replace("_drafts/", "")}`
|
||||
const newMeta: NoteMeta = { ...meta, slug: newSlug, topic: newTopic, draft: true, draftStage: "in-review" }
|
||||
cacheRef.current.delete(slug)
|
||||
setDrafts(prev => prev.filter(n => n.slug !== slug))
|
||||
setTopics(prev => {
|
||||
const existing = prev.find(t => t.slug === newTopic)
|
||||
if (existing) return prev.map(t => t.slug === newTopic ? { ...t, notes: [...t.notes, newMeta] } : t)
|
||||
return [...prev, { slug: newTopic, title: newTopic, notes: [newMeta] }]
|
||||
})
|
||||
selectNote(newSlug)
|
||||
}
|
||||
}, [selectNote])
|
||||
|
||||
/* ── updateActiveNote ──────────────────────────────────────────── */
|
||||
|
||||
const updateActiveNote = useCallback((slug: string, updates: Partial<NoteContent>) => {
|
||||
const cached = cacheRef.current.get(slug)
|
||||
if (cached) cacheRef.current.set(slug, { ...cached, ...updates })
|
||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, ...updates } : prev)
|
||||
setTopics(prev => prev.map(t => ({
|
||||
...t,
|
||||
notes: t.notes.map(n => n.slug === slug ? { ...n, ...updates } : n),
|
||||
})))
|
||||
}, [])
|
||||
|
||||
/* ── loadNotes ──────────────────────────────────────────────────── */
|
||||
|
||||
const loadNotes = useCallback(() => {
|
||||
if (DEV) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
getNotes()
|
||||
.then((items) => {
|
||||
const { topics: newTopics, drafts: newDrafts } = buildTopicTree(items)
|
||||
|
||||
setTopics(newTopics)
|
||||
setDrafts(newDrafts)
|
||||
topicsRef.current = newTopics
|
||||
draftsRef.current = newDrafts
|
||||
|
||||
const firstSlug = newTopics[0]?.notes[0]?.slug
|
||||
?? newTopics[0]?.subTopics?.[0]?.notes[0]?.slug
|
||||
if (firstSlug) selectNote(firstSlug)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
const msg = e instanceof NozApiError ? e.message : "Notizen konnten nicht geladen werden"
|
||||
setError(msg)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [selectNote])
|
||||
|
||||
useEffect(() => {
|
||||
if (!DEV) loadNotes()
|
||||
}, [loadNotes])
|
||||
|
||||
return {
|
||||
topics, drafts, loading, error,
|
||||
activeNote, noteLoading, isOnline, hasPending,
|
||||
selectNote, saveNote, createNote, deleteNote,
|
||||
renameNote, toggleDraft, promoteNote, updateActiveNote,
|
||||
refreshNotes: loadNotes,
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Recurse into subTopics — `flatMap(t => t.notes)` only covers one level. */
|
||||
function flattenNotes(topics: Topic[]): NoteMeta[] {
|
||||
return topics.flatMap(t => [...t.notes, ...flattenNotes(t.subTopics ?? [])])
|
||||
}
|
||||
|
||||
/** Find a NoteMeta by slug anywhere in the topic tree. */
|
||||
function findNoteMeta(topics: Topic[], slug: string): NoteMeta | undefined {
|
||||
return flattenNotes(topics).find(n => n.slug === slug)
|
||||
}
|
||||
|
||||
/* ── buildTopicTree ─────────────────────────────────────────────────────────
|
||||
Baut den Ordner-Baum aus Slug-Pfaden auf, nicht aus dem topic-Frontmatter-Feld.
|
||||
Beispiel:
|
||||
"biosysteme/bio-regenerative-systeme/hydroponik"
|
||||
→ biosysteme (root) → bio-regenerative-systeme (subtopic) → hydroponik (note)
|
||||
──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function buildTopicTree(items: NoteListItem[]): { topics: Topic[]; drafts: NoteMeta[] } {
|
||||
const folderNotes = new Map<string, NoteMeta[]>()
|
||||
const newDrafts: NoteMeta[] = []
|
||||
|
||||
function ensureFolder(slug: string) {
|
||||
if (folderNotes.has(slug)) return
|
||||
folderNotes.set(slug, [])
|
||||
if (slug.includes("/")) ensureFolder(slug.slice(0, slug.lastIndexOf("/")))
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const meta: NoteMeta = {
|
||||
slug: item.slug,
|
||||
title: item.title,
|
||||
topic: item.topic,
|
||||
status: item.status as NoteStatus,
|
||||
lang: item.lang as Lang,
|
||||
draft: item.draft,
|
||||
draftStage: item.slug.startsWith("_drafts/") ? "draft"
|
||||
: item.draft ? "in-review"
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (item.slug.startsWith("_drafts/")) {
|
||||
newDrafts.push(meta)
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = item.slug.split("/")
|
||||
// Übergeordneter Ordner = alle Segmente außer dem letzten (Dateinamen)
|
||||
// Bei Root-Notizen ohne Ordner (z.B. "mhc-komplex") → topic-Feld verwenden
|
||||
const folderSlug = parts.length >= 2
|
||||
? parts.slice(0, -1).join("/")
|
||||
: item.topic
|
||||
|
||||
ensureFolder(folderSlug)
|
||||
folderNotes.get(folderSlug)!.push(meta)
|
||||
}
|
||||
|
||||
function folderLabel(slug: string): string {
|
||||
const last = slug.split("/").pop()!
|
||||
return last.charAt(0).toUpperCase() + last.slice(1).replace(/-/g, " ")
|
||||
}
|
||||
|
||||
function buildTopic(slug: string): Topic {
|
||||
const notes = folderNotes.get(slug) ?? []
|
||||
const subTopics: Topic[] = [...folderNotes.keys()]
|
||||
.filter(f => f.startsWith(slug + "/") && !f.slice(slug.length + 1).includes("/"))
|
||||
.sort()
|
||||
.map(buildTopic)
|
||||
|
||||
return {
|
||||
slug,
|
||||
title: folderLabel(slug),
|
||||
notes,
|
||||
subTopics: subTopics.length > 0 ? subTopics : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const roots = [...folderNotes.keys()]
|
||||
.filter(f => {
|
||||
if (!f.includes("/")) return true
|
||||
return !folderNotes.has(f.slice(0, f.lastIndexOf("/")))
|
||||
})
|
||||
.sort()
|
||||
|
||||
return { topics: roots.map(buildTopic), drafts: newDrafts }
|
||||
}
|
||||
129
src/i18n/de.ts
Normal file
129
src/i18n/de.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Alle Übersetzungs-Keys hier definiert — de.ts ist der Source of Truth.
|
||||
// Translations-Interface hier damit en.ts es importieren kann.
|
||||
|
||||
export interface Translations {
|
||||
// Navbar
|
||||
sidebar: string
|
||||
home: string
|
||||
search: string
|
||||
settings: string
|
||||
editorOnly: string
|
||||
split: string
|
||||
previewOnly: string
|
||||
// Status
|
||||
seed: string
|
||||
budding: string
|
||||
evergreen: string
|
||||
draft: string
|
||||
inReview: string
|
||||
live: string
|
||||
// StatusBar
|
||||
words: string
|
||||
saved: string
|
||||
unsaved: string
|
||||
// FolderTree
|
||||
noosphere: string
|
||||
newNote: string
|
||||
newFolder: string
|
||||
subfolder: string
|
||||
drafts: string
|
||||
rename: string
|
||||
moveTo: string
|
||||
delete: string
|
||||
cancel: string
|
||||
noteName: string
|
||||
folderName: string
|
||||
confirm: string
|
||||
noOtherFolders: string
|
||||
// Lösch-Dialog
|
||||
deleteNote: string
|
||||
deleteWarning: string
|
||||
// CommandPalette
|
||||
searchPlaceholder: string
|
||||
searchTermPlaceholder: string
|
||||
scopeLabel: string
|
||||
webSearch: (q: string) => string
|
||||
webSearchSub: string
|
||||
webSearchHint: string
|
||||
noResults: string
|
||||
recentlyOpened: string
|
||||
navigate: string
|
||||
open: string
|
||||
close: string
|
||||
// Login
|
||||
loginServer: string
|
||||
loginToken: string
|
||||
loginConnect: string
|
||||
loginConnecting: string
|
||||
loginAuthentik: string
|
||||
loginHint: string
|
||||
loginError: string
|
||||
loginOr: string
|
||||
// Loading
|
||||
loadingNotes: string
|
||||
loadingNote: string
|
||||
}
|
||||
|
||||
const de: Translations = {
|
||||
sidebar: "Sidebar",
|
||||
home: "Startseite",
|
||||
search: "Suchen",
|
||||
settings: "Einstellungen",
|
||||
editorOnly: "Nur Editor",
|
||||
split: "Split",
|
||||
previewOnly: "Nur Preview",
|
||||
|
||||
seed: "Seed",
|
||||
budding: "Budding",
|
||||
evergreen: "Evergreen",
|
||||
draft: "Entwurf",
|
||||
inReview: "In Review",
|
||||
live: "Live",
|
||||
|
||||
words: "Wörter",
|
||||
saved: "Gespeichert",
|
||||
unsaved: "Nicht gespeichert",
|
||||
|
||||
noosphere: "Noosphere",
|
||||
newNote: "Neue Notiz",
|
||||
newFolder: "Neuer Ordner",
|
||||
subfolder: "Unterordner",
|
||||
drafts: "Entwürfe",
|
||||
rename: "Umbenennen",
|
||||
moveTo: "Verschieben nach",
|
||||
delete: "Löschen",
|
||||
cancel: "Abbrechen",
|
||||
noteName: "Notiz-Titel…",
|
||||
folderName: "Ordnername…",
|
||||
confirm: "Bestätigen",
|
||||
noOtherFolders: "Keine anderen Ordner",
|
||||
|
||||
deleteNote: "Notiz löschen",
|
||||
deleteWarning: "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
|
||||
searchPlaceholder: "Suchen… @topic · @web · @draft",
|
||||
searchTermPlaceholder: "Suchbegriff…",
|
||||
scopeLabel: "Einschränken",
|
||||
webSearch: (q) => `Im Web suchen: "${q}"`,
|
||||
webSearchSub: "DuckDuckGo — öffnet neuen Tab",
|
||||
webSearchHint: "@web für direkten Web-Fokus · öffnet neuen Tab",
|
||||
noResults: "Keine Notizen gefunden",
|
||||
recentlyOpened: "Zuletzt geöffnet",
|
||||
navigate: "navigieren",
|
||||
open: "öffnen",
|
||||
close: "schließen",
|
||||
|
||||
loginServer: "Server",
|
||||
loginToken: "Token",
|
||||
loginConnect: "Verbinden",
|
||||
loginConnecting: "Verbinde…",
|
||||
loginAuthentik: "Mit Authentik anmelden",
|
||||
loginHint: "Token aus",
|
||||
loginError: "Bitte Server-URL und Token eingeben.",
|
||||
loginOr: "oder",
|
||||
|
||||
loadingNotes: "Notizen laden…",
|
||||
loadingNote: "Lade Notiz…",
|
||||
}
|
||||
|
||||
export default de
|
||||
73
src/i18n/en.ts
Normal file
73
src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { Translations } from "./de"
|
||||
|
||||
const en: Translations = {
|
||||
// ── Editor-Navbar ──────────────────────────────────────────────────────────
|
||||
sidebar: "Sidebar",
|
||||
home: "Home",
|
||||
search: "Search",
|
||||
settings: "Settings",
|
||||
editorOnly: "Editor only",
|
||||
split: "Split",
|
||||
previewOnly: "Preview only",
|
||||
|
||||
// ── Status ─────────────────────────────────────────────────────────────────
|
||||
seed: "Seed",
|
||||
budding: "Budding",
|
||||
evergreen: "Evergreen",
|
||||
draft: "Draft",
|
||||
inReview: "In Review",
|
||||
live: "Live",
|
||||
|
||||
// ── StatusBar ──────────────────────────────────────────────────────────────
|
||||
words: "Words",
|
||||
saved: "Saved",
|
||||
unsaved: "Unsaved",
|
||||
|
||||
// ── FolderTree ─────────────────────────────────────────────────────────────
|
||||
noosphere: "Noosphere",
|
||||
newNote: "New note",
|
||||
newFolder: "New folder",
|
||||
subfolder: "Subfolder",
|
||||
drafts: "Drafts",
|
||||
rename: "Rename",
|
||||
moveTo: "Move to",
|
||||
delete: "Delete",
|
||||
cancel: "Cancel",
|
||||
noteName: "Note title…",
|
||||
folderName: "Folder name…",
|
||||
confirm: "Confirm",
|
||||
noOtherFolders: "No other folders",
|
||||
|
||||
// ── Lösch-Dialog ───────────────────────────────────────────────────────────
|
||||
deleteNote: "Delete note",
|
||||
deleteWarning: "This action cannot be undone.",
|
||||
|
||||
// ── CommandPalette ─────────────────────────────────────────────────────────
|
||||
searchPlaceholder: "Search… @topic · @web · @draft",
|
||||
searchTermPlaceholder: "Search term…",
|
||||
scopeLabel: "Filter by",
|
||||
webSearch: (q: string) => `Search the web: "${q}"`,
|
||||
webSearchSub: "DuckDuckGo — opens new tab",
|
||||
webSearchHint: "@web for direct web search · opens new tab",
|
||||
noResults: "No notes found",
|
||||
recentlyOpened: "Recently opened",
|
||||
navigate: "navigate",
|
||||
open: "open",
|
||||
close: "close",
|
||||
|
||||
// ── Login ──────────────────────────────────────────────────────────────────
|
||||
loginServer: "Server",
|
||||
loginToken: "Token",
|
||||
loginConnect: "Connect",
|
||||
loginConnecting: "Connecting…",
|
||||
loginAuthentik: "Sign in with Authentik",
|
||||
loginHint: "Token from",
|
||||
loginError: "Please enter a server URL and token.",
|
||||
loginOr: "or",
|
||||
|
||||
// ── Ladezeiten ─────────────────────────────────────────────────────────────
|
||||
loadingNotes: "Loading notes…",
|
||||
loadingNote: "Loading note…",
|
||||
}
|
||||
|
||||
export default en
|
||||
12
src/i18n/index.ts
Normal file
12
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import de from "./de"
|
||||
import en from "./en"
|
||||
import type { Translations } from "./de"
|
||||
|
||||
export type { Translations }
|
||||
export type Locale = "de" | "en"
|
||||
|
||||
const map: Record<Locale, Translations> = { de, en }
|
||||
|
||||
export function getTranslations(locale: Locale): Translations {
|
||||
return map[locale] ?? de
|
||||
}
|
||||
230
src/lib/api.ts
Normal file
230
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { getToken, getServerUrl, clearCredentials } from "./auth";
|
||||
import type { NoteMeta } from "./types";
|
||||
|
||||
/* ── Fehlertyp ──────────────────────────────────────── */
|
||||
|
||||
export class NozApiError extends Error {
|
||||
constructor(public readonly status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "NozApiError";
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Studio-Config (öffentlich, kein Token) ─────────── */
|
||||
|
||||
export interface StudioServerConfig {
|
||||
name: string;
|
||||
lang: string;
|
||||
auth: "token" | "authentik" | "both";
|
||||
allow_push: boolean;
|
||||
allow_delete: boolean;
|
||||
allow_rebuild: boolean;
|
||||
}
|
||||
|
||||
export async function fetchStudioConfig(serverUrl: string): Promise<StudioServerConfig> {
|
||||
const url = serverUrl.replace(/\/$/, "");
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${url}/api/studio/config`, { cache: "no-store" });
|
||||
} catch {
|
||||
throw new NozApiError(0, "Server nicht erreichbar");
|
||||
}
|
||||
if (res.status === 404) throw new NozApiError(404, "Kein noz-Server unter dieser URL");
|
||||
if (!res.ok) throw new NozApiError(res.status, "Server-Fehler");
|
||||
return res.json() as Promise<StudioServerConfig>;
|
||||
}
|
||||
|
||||
/* ── Authentifizierter Request-Helper ───────────────── */
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const url = getServerUrl();
|
||||
const token = getToken();
|
||||
|
||||
if (!url || !token) {
|
||||
clearCredentials();
|
||||
if (typeof window !== "undefined") window.location.href = "/login";
|
||||
throw new NozApiError(401, "Nicht angemeldet");
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${url}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new NozApiError(0, "Netzwerkfehler — Server nicht erreichbar");
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
clearCredentials();
|
||||
if (typeof window !== "undefined") window.location.href = "/login";
|
||||
throw new NozApiError(res.status, "Sitzung abgelaufen");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { error?: string };
|
||||
throw new NozApiError(res.status, body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/* ── Token testen ───────────────────────────────────── */
|
||||
|
||||
export async function testToken(serverUrl: string, token: string): Promise<void> {
|
||||
const url = serverUrl.replace(/\/$/, "");
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${url}/api/cli/notes`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
throw new NozApiError(0, "Server nicht erreichbar");
|
||||
}
|
||||
if (res.status === 401) throw new NozApiError(401, "Token ungültig");
|
||||
if (res.status === 403) throw new NozApiError(403, "Zugriff verweigert — CLI deaktiviert");
|
||||
if (!res.ok) throw new NozApiError(res.status, "Server-Fehler");
|
||||
}
|
||||
|
||||
/* ── Notes ──────────────────────────────────────────── */
|
||||
|
||||
export interface NoteListItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
status: string;
|
||||
topic: string;
|
||||
lang: string;
|
||||
private: boolean;
|
||||
draft: boolean;
|
||||
hash: string | null;
|
||||
}
|
||||
|
||||
export async function getNotes(): Promise<NoteListItem[]> {
|
||||
const { notes } = await request<{ notes: NoteListItem[] }>("/api/cli/notes");
|
||||
return notes;
|
||||
}
|
||||
|
||||
export async function getNote(slug: string): Promise<{ slug: string; markdown: string; hash: string }> {
|
||||
return request<{ slug: string; markdown: string; hash: string }>(`/api/cli/notes/${slug}`);
|
||||
}
|
||||
|
||||
export async function putNote(slug: string, markdown: string): Promise<{ hash: string }> {
|
||||
return request<{ hash: string }>(`/api/cli/notes/${slug}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ markdown }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteNote(slug: string): Promise<void> {
|
||||
await request(`/api/cli/notes/${slug}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function patchNote(
|
||||
slug: string,
|
||||
patch: { title?: string; draft?: boolean; private?: boolean },
|
||||
): Promise<{ hash: string }> {
|
||||
return request<{ hash: string }>(`/api/cli/notes/${slug}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveNote(slug: string, newTopic: string): Promise<void> {
|
||||
await request(`/api/cli/notes/${slug}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newTopic }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNote(
|
||||
title: string,
|
||||
topic?: string,
|
||||
status = "seed",
|
||||
lang = "de",
|
||||
): Promise<{ slug: string; hash: string }> {
|
||||
return request<{ slug: string; hash: string }>("/api/cli/notes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, ...(topic ? { topic } : {}), status, lang }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function promoteNote(slug: string, newTopic: string): Promise<{ newSlug: string }> {
|
||||
const { newSlug } = await moveNoteApi(slug, newTopic);
|
||||
await patchNote(newSlug, { draft: true });
|
||||
return { newSlug };
|
||||
}
|
||||
|
||||
/* ── Ordner ─────────────────────────────────────────── */
|
||||
|
||||
export async function createFolder(slug: string, title: string): Promise<void> {
|
||||
await request(`/api/cli/folders/${slug}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameFolder(slug: string, newSlug: string): Promise<void> {
|
||||
await request(`/api/cli/folders/${slug}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ newSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFolder(slug: string): Promise<void> {
|
||||
await request(`/api/cli/folders/${slug}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function moveNoteApi(slug: string, newTopic: string): Promise<{ newSlug: string }> {
|
||||
return request<{ newSlug: string }>(`/api/cli/notes/${slug}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ newTopic }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Diagramm ───────────────────────────────────────── */
|
||||
|
||||
import type { DiagramNode, DiagramEdge, DiagramDirection } from "@/components/diagram/types"
|
||||
|
||||
export interface DiagramData {
|
||||
toml: string
|
||||
direction: DiagramDirection
|
||||
nodes: DiagramNode[]
|
||||
edges: DiagramEdge[]
|
||||
}
|
||||
|
||||
export async function getDiagramData(folderSlug: string): Promise<DiagramData> {
|
||||
return request<DiagramData>(`/api/cli/diagram/${folderSlug}`)
|
||||
}
|
||||
|
||||
export async function putDiagramToml(folderSlug: string, toml: string): Promise<void> {
|
||||
await request(`/api/cli/diagram/${folderSlug}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ toml }),
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Suche ──────────────────────────────────────────── */
|
||||
|
||||
export interface SearchHit extends Pick<NoteMeta, "slug" | "title" | "topic" | "lang"> {
|
||||
description?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export async function search(q: string, topic?: string, limit = 20): Promise<SearchHit[]> {
|
||||
const p = new URLSearchParams({ q, limit: String(limit) });
|
||||
if (topic) p.set("topic", topic);
|
||||
const { hits } = await request<{ hits: SearchHit[] }>(`/api/cli/search?${p}`);
|
||||
return hits;
|
||||
}
|
||||
|
||||
/* ── Index-Rebuild ──────────────────────────────────── */
|
||||
|
||||
export async function triggerRebuild(): Promise<void> {
|
||||
await request("/api/cli/rebuild", { method: "POST" });
|
||||
}
|
||||
34
src/lib/auth.ts
Normal file
34
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
const KEY_TOKEN = "noz_token";
|
||||
const KEY_URL = "noz_url";
|
||||
|
||||
function ss(): Storage | null {
|
||||
return typeof window !== "undefined" ? sessionStorage : null;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return ss()?.getItem(KEY_TOKEN) ?? null;
|
||||
}
|
||||
|
||||
export function getServerUrl(): string | null {
|
||||
return ss()?.getItem(KEY_URL) ?? null;
|
||||
}
|
||||
|
||||
export function setCredentials(serverUrl: string, token: string): void {
|
||||
const s = ss();
|
||||
if (!s) return;
|
||||
// Trailing-Slash entfernen
|
||||
s.setItem(KEY_URL, serverUrl.replace(/\/$/, ""));
|
||||
s.setItem(KEY_TOKEN, token);
|
||||
}
|
||||
|
||||
export function clearCredentials(): void {
|
||||
const s = ss();
|
||||
if (!s) return;
|
||||
s.removeItem(KEY_TOKEN);
|
||||
s.removeItem(KEY_URL);
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
if (process.env.NEXT_PUBLIC_DEV_FIXTURES === "true") return true;
|
||||
return !!(getToken() && getServerUrl());
|
||||
}
|
||||
286
src/lib/dev-fixtures.ts
Normal file
286
src/lib/dev-fixtures.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* Entwicklungs-Fixtures — echte Beispielnotizen für lokale Entwicklung ohne API.
|
||||
* Werden importiert wenn NEXT_PUBLIC_USE_FIXTURES=true gesetzt ist,
|
||||
* oder wenn kein API-Token vorhanden ist.
|
||||
*
|
||||
* Diese Notizen spiegeln die tatsächlichen Inhalte aus dem noz-Content-Store wider
|
||||
* und dienen als realistische Testdaten für Editor, Preview und Sidebar.
|
||||
*/
|
||||
|
||||
import type { NoteContent, Topic } from "./types"
|
||||
|
||||
export const FIXTURE_NOTES: NoteContent[] = [
|
||||
{
|
||||
slug: "psychologie/flow-theorie",
|
||||
title: "Flow-Theorie",
|
||||
description: "Csikszentmihalyi's Konzept des optimalen Erlebens",
|
||||
topic: "psychologie",
|
||||
status: "evergreen",
|
||||
lang: "de",
|
||||
content: `# Flow-Theorie
|
||||
|
||||
Die **Flow-Theorie** wurde von Mihaly Csikszentmihalyi in den 1970er Jahren entwickelt. Sie beschreibt einen mentalen Zustand vollständiger Vertiefung und Aufgehens in einer Tätigkeit.
|
||||
|
||||
## Kernkonzept
|
||||
|
||||
Flow entsteht wenn Herausforderung und Fähigkeit in einem *optimalen Verhältnis* stehen — weder zu leicht (Langeweile) noch zu schwer (Angst und Überforderung).
|
||||
|
||||
## Merkmale des Flow-Zustands
|
||||
|
||||
- Vollständige Konzentration auf die aktuelle Aufgabe
|
||||
- Verlust des Zeitgefühls — Stunden vergehen wie Minuten
|
||||
- Effortless action — Handeln fühlt sich mühelos an
|
||||
- Klares Ziel und unmittelbares Feedback
|
||||
- Verschmelzung von Handlung und Bewusstsein
|
||||
|
||||
## Flow-Kanal
|
||||
|
||||
Csikszentmihalyi beschreibt einen "Flow-Kanal" zwischen Angst und Langeweile. Optimale Erfahrungen entstehen genau in diesem Kanal.
|
||||
|
||||
\`\`\`
|
||||
Hohe Herausforderung
|
||||
│ Flow-Kanal
|
||||
│ ╱
|
||||
│ ╱
|
||||
│╱
|
||||
└────────────────
|
||||
Hohe Fähigkeit
|
||||
\`\`\`
|
||||
|
||||
## Mathematische Modellierung
|
||||
|
||||
Der optimale Erlebniskanal lässt sich vereinfacht darstellen als:
|
||||
|
||||
$$F = \\{(c, f) \\mid c \\approx f,\\; c > c_{\\min}\\}$$
|
||||
|
||||
wobei $c$ die Herausforderung und $f$ die Fähigkeit bezeichnet.
|
||||
|
||||
## Anwendung
|
||||
|
||||
Flow lässt sich durch bewusste [[Umgebungsgestaltung]] und Praktiken wie [[Deep Work]] fördern. Entscheidend ist die Kalibrierung von Schwierigkeit und eigenem Können.
|
||||
|
||||
## Verwandte Konzepte
|
||||
|
||||
- [[Intrinsische Motivation]]
|
||||
- [[psychologie/prokrastination]] — das Gegenteil von Flow
|
||||
- [[Zustand des Nicht-Handelns]] (Wu Wei)`,
|
||||
},
|
||||
{
|
||||
slug: "psychologie/prokrastination",
|
||||
title: "Prokrastination",
|
||||
description: "Das Aufschieben von Aufgaben und seine psychologischen Wurzeln",
|
||||
topic: "psychologie",
|
||||
status: "budding",
|
||||
lang: "de",
|
||||
content: `# Prokrastination
|
||||
|
||||
Prokrastination ist mehr als Faulheit — sie ist primär eine **emotionale Regulationsstrategie**.
|
||||
|
||||
## Psychologische Wurzeln
|
||||
|
||||
Menschen schieben nicht auf weil sie faul sind, sondern weil sie versuchen negative Gefühle zu vermeiden die mit einer Aufgabe verbunden sind.
|
||||
|
||||
### Häufige Auslöser
|
||||
|
||||
- **Perfektionismus** — Angst vor unzureichenden Ergebnissen
|
||||
- **Aufgaben-Aversion** — die Aufgabe selbst fühlt sich unangenehm an
|
||||
- **Schwache Selbstregulation** — mangelnde Impulskontrolle
|
||||
- **Vage Ziele** — fehlende Klarheit über den nächsten Schritt
|
||||
|
||||
## Strategien
|
||||
|
||||
1. Implementation Intentions — "Wenn X dann Y" Pläne
|
||||
2. Timeboxing — feste Zeitblöcke für unangenehme Aufgaben
|
||||
3. Aufgaben verkleinern — Next Actions statt große Projekte
|
||||
|
||||
## Zusammenhang mit Flow
|
||||
|
||||
Prokrastination und [[psychologie/flow-theorie]] sind Gegenpole. Während Flow durch optimale Passung von Herausforderung und Fähigkeit entsteht, entsteht Prokrastination oft wenn die Herausforderung zu diffus oder emotional belastet ist.`,
|
||||
},
|
||||
{
|
||||
slug: "psychologie/kognitive-verzerrungen",
|
||||
title: "Kognitive Verzerrungen",
|
||||
description: "Systematische Denkfehler und wie sie unser Urteil trüben",
|
||||
topic: "psychologie",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
content: `# Kognitive Verzerrungen
|
||||
|
||||
Kognitive Verzerrungen sind systematische Muster irrationalen Denkens die unser Urteil, unsere Wahrnehmung und unsere Entscheidungen beeinflussen.
|
||||
|
||||
## Bekannte Verzerrungen
|
||||
|
||||
- **Bestätigungsfehler** — wir suchen Informationen die unsere bestehenden Ansichten bestätigen
|
||||
- **Verfügbarkeitsheuristik** — leicht erinnerbare Ereignisse werden in ihrer Häufigkeit überschätzt
|
||||
- **Dunning-Kruger-Effekt** — inkompetente Menschen überschätzen ihre eigenen Fähigkeiten
|
||||
- **Ankerheuristik** — erste Information verankert spätere Urteile unverhältnismäßig stark
|
||||
- **Survivorship Bias** — wir sehen nur die Überlebenden, nicht die Gescheiterten
|
||||
|
||||
## Bayesianische Perspektive
|
||||
|
||||
Aus bayesianischer Sicht sind viele Verzerrungen schlechte Priorenaktualisierungen:
|
||||
|
||||
$$P(H|E) = \\frac{P(E|H) \\cdot P(H)}{P(E)}$$
|
||||
|
||||
Der Bestätigungsfehler entspricht dem Überschätzen von $P(E|H)$ wenn $H$ unserer Überzeugung entspricht.`,
|
||||
},
|
||||
{
|
||||
slug: "biosysteme/brs-konzept",
|
||||
title: "BRS-Konzept",
|
||||
description: "Biologische Rückkopplungssysteme in komplexen Ökosystemen",
|
||||
topic: "biosysteme",
|
||||
status: "evergreen",
|
||||
lang: "de",
|
||||
content: `# BRS-Konzept
|
||||
|
||||
Das BRS-Konzept beschreibt **Biologische Rückkopplungssysteme** — selbstregulierende Mechanismen in komplexen Ökosystemen.
|
||||
|
||||
## Grundprinzip
|
||||
|
||||
Ein Rückkopplungssystem reagiert auf seinen eigenen Output und passt sein Verhalten entsprechend an. In biologischen Systemen sorgt dies für Homöostase.
|
||||
|
||||
## Positive vs. Negative Rückkopplung
|
||||
|
||||
| Typ | Wirkung | Beispiel |
|
||||
|-----|---------|---------|
|
||||
| Negativ | Stabilisierend | Körpertemperatur, pH-Wert im Blut |
|
||||
| Positiv | Verstärkend | Blutgerinnung, Wehen, Aktionspotential |
|
||||
|
||||
## Systemgleichgewicht
|
||||
|
||||
Das Gleichgewicht eines Rückkopplungssystems lässt sich als Fixpunkt beschreiben:
|
||||
|
||||
$$\\dot{x} = f(x) = 0 \\implies x^* \\text{ (Gleichgewicht)}$$
|
||||
|
||||
Stabiles Gleichgewicht: $f'(x^*) < 0$ (negative Rückkopplung dominiert).
|
||||
|
||||
## Relevanz für Ökosysteme
|
||||
|
||||
In Ökosystemen sorgen Räuber-Beute-Zyklen ([[Lotka-Volterra]]) für dynamisches Gleichgewicht — ein klassisches Beispiel verschränkter Rückkopplungsschleifen.`,
|
||||
},
|
||||
{
|
||||
slug: "biosysteme/hydroponik",
|
||||
title: "Hydroponik",
|
||||
description: "Pflanzenanbau ohne Erde — Grundlagen und Systeme",
|
||||
topic: "biosysteme",
|
||||
status: "budding",
|
||||
lang: "de",
|
||||
content: `# Hydroponik
|
||||
|
||||
Hydroponik ist der Anbau von Pflanzen in Nährlösung ohne Erde als Substrat.
|
||||
|
||||
## Vorteile
|
||||
|
||||
- Bis zu 90 % weniger Wasserverbrauch gegenüber Erdanbau
|
||||
- Schnelleres Wachstum durch optimale und steuerbare Nährstoffversorgung
|
||||
- Unabhängig von Bodenbeschaffenheit, Klima und Jahreszeit
|
||||
- Keine Schädlinge aus dem Boden, weniger Pestizide nötig
|
||||
|
||||
## Systemtypen
|
||||
|
||||
\`\`\`
|
||||
NFT (Nutrient Film Technique) — dünner Nährlösungsfilm
|
||||
DWC (Deep Water Culture) — Wurzeln hängen in Lösung
|
||||
Ebb & Flow — zyklisches Fluten
|
||||
Aeroponik — Wurzeln werden besprüht
|
||||
\`\`\`
|
||||
|
||||
## Nährlösung
|
||||
|
||||
Die optimale EC-Leitfähigkeit liegt je nach Pflanze zwischen 1,5 und 2,5 mS/cm. pH-Wert wird auf 5,5–6,5 gehalten (siehe [[biosysteme/brs-konzept]] für Rückkopplungsregelung).`,
|
||||
},
|
||||
{
|
||||
slug: "quantenmechanik/superposition",
|
||||
title: "Superposition",
|
||||
description: "Das Prinzip der Superposition in der Quantenmechanik",
|
||||
topic: "quantenmechanik",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
content: `# Superposition
|
||||
|
||||
Das **Superpositionsprinzip** besagt, dass ein Quantensystem sich gleichzeitig in mehreren Zuständen befinden kann, bis eine Messung stattfindet und den Zustand kollabieren lässt.
|
||||
|
||||
## Mathematische Darstellung
|
||||
|
||||
Ein Qubit in Superposition:
|
||||
|
||||
$$|\\psi\\rangle = \\alpha|0\\rangle + \\beta|1\\rangle$$
|
||||
|
||||
wobei die Normierungsbedingung gilt:
|
||||
|
||||
$$|\\alpha|^2 + |\\beta|^2 = 1$$
|
||||
|
||||
Die Koeffizienten $\\alpha, \\beta \\in \\mathbb{C}$ sind die Wahrscheinlichkeitsamplituden.
|
||||
|
||||
## Schrödinger's Katze
|
||||
|
||||
Das bekannteste Gedankenexperiment zur Superposition — eine Katze befindet sich gleichzeitig in einem lebendigen und toten Zustand solange das System unbeobachtet bleibt.
|
||||
|
||||
## Dekohärenz
|
||||
|
||||
In der Praxis geht Superposition durch Wechselwirkung mit der Umgebung verloren (**Dekohärenz**). Je größer das System, desto schneller kollabiert die Superposition — deshalb sind Quanteneffekte makroskopisch nicht sichtbar.
|
||||
|
||||
## Zusammenhang mit Verschränkung
|
||||
|
||||
Superposition ist Voraussetzung für [[Quantenverschränkung]]. Zwei verschränkte Qubits teilen einen gemeinsamen Superpositionszustand der nicht als Produkt zweier Einzelzustände geschrieben werden kann.`,
|
||||
},
|
||||
]
|
||||
|
||||
export const FIXTURE_DRAFTS: NoteContent[] = [
|
||||
{
|
||||
slug: "_drafts/neue-gedanken",
|
||||
title: "Neue Gedanken",
|
||||
description: "",
|
||||
topic: "_drafts",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
draftStage: "draft",
|
||||
content: `# Neue Gedanken
|
||||
|
||||
Idee: Verbindung zwischen [[psychologie/flow-theorie]] und Kreativität untersuchen.
|
||||
|
||||
Was passiert im Gehirn während Flow-Zuständen? Gibt es Parallelen zur Meditation?
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- Kann man Flow künstlich induzieren?
|
||||
- Rolle des Default Mode Network beim Wechsel in Flow
|
||||
- Zusammenhang mit intrinsischer Motivation`,
|
||||
},
|
||||
{
|
||||
slug: "_drafts/notiz-ohne-titel",
|
||||
title: "Unbenannte Notiz",
|
||||
description: "",
|
||||
topic: "_drafts",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
draftStage: "draft",
|
||||
content: `# Unbenannte Notiz
|
||||
|
||||
Schnelle Notiz — noch kein Thema gefunden.
|
||||
|
||||
## Fragment
|
||||
|
||||
$$e^{i\\pi} + 1 = 0$$
|
||||
|
||||
Eulers Identität — vielleicht der schönste Satz der Mathematik. Verbindet die fünf fundamentalsten Konstanten der Mathematik in einer einzigen Gleichung.`,
|
||||
},
|
||||
]
|
||||
|
||||
export const FIXTURE_TOPICS: Topic[] = [
|
||||
{
|
||||
slug: "psychologie",
|
||||
title: "Psychologie",
|
||||
notes: FIXTURE_NOTES.filter((n) => n.topic === "psychologie"),
|
||||
},
|
||||
{
|
||||
slug: "biosysteme",
|
||||
title: "Biosysteme",
|
||||
notes: FIXTURE_NOTES.filter((n) => n.topic === "biosysteme"),
|
||||
},
|
||||
{
|
||||
slug: "quantenmechanik",
|
||||
title: "Quantenmechanik",
|
||||
notes: FIXTURE_NOTES.filter((n) => n.topic === "quantenmechanik"),
|
||||
},
|
||||
]
|
||||
72
src/lib/frontmatter.ts
Normal file
72
src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { NoteContent, NoteStatus, Lang } from "./types"
|
||||
|
||||
export interface FrontmatterFields {
|
||||
title: string
|
||||
description: string
|
||||
topic: string
|
||||
status: NoteStatus
|
||||
lang: Lang
|
||||
draft?: boolean
|
||||
order?: number
|
||||
}
|
||||
|
||||
const DEFAULT: FrontmatterFields = {
|
||||
title: "", description: "", topic: "", status: "seed", lang: "de",
|
||||
}
|
||||
|
||||
export function splitContent(markdown: string): { fields: FrontmatterFields; body: string } {
|
||||
if (!markdown.startsWith("---\n")) return { fields: { ...DEFAULT }, body: markdown }
|
||||
|
||||
const end = markdown.indexOf("\n---\n", 4)
|
||||
if (end === -1) return { fields: { ...DEFAULT }, body: markdown }
|
||||
|
||||
const yamlStr = markdown.slice(4, end)
|
||||
const body = markdown.slice(end + 5)
|
||||
const fields = { ...DEFAULT }
|
||||
|
||||
for (const line of yamlStr.split("\n")) {
|
||||
const m = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (!m) continue
|
||||
const [, key, rawVal] = m
|
||||
const val = rawVal.replace(/^["']|["']$/g, "").trim()
|
||||
switch (key) {
|
||||
case "title": fields.title = val; break
|
||||
case "description": fields.description = val; break
|
||||
case "topic": fields.topic = val; break
|
||||
case "status": fields.status = val as NoteStatus; break
|
||||
case "lang": fields.lang = val as Lang; break
|
||||
case "draft": fields.draft = rawVal.trim() === "true"; break
|
||||
case "order": { const n = parseInt(rawVal.trim()); if (!isNaN(n)) fields.order = n } break
|
||||
}
|
||||
}
|
||||
|
||||
return { fields, body }
|
||||
}
|
||||
|
||||
export function buildMarkdown(note: NoteContent | FrontmatterFields, body: string): string {
|
||||
const f = "content" in note ? noteToFields(note) : note
|
||||
const lines = [
|
||||
"---",
|
||||
`title: "${f.title.replace(/"/g, '\\"')}"`,
|
||||
`description: "${f.description.replace(/"/g, '\\"')}"`,
|
||||
`topic: ${f.topic}`,
|
||||
`status: ${f.status}`,
|
||||
`lang: ${f.lang}`,
|
||||
]
|
||||
if (f.draft) lines.push("draft: true")
|
||||
if (f.order) lines.push(`order: ${f.order}`)
|
||||
lines.push("---", "")
|
||||
return lines.join("\n") + body
|
||||
}
|
||||
|
||||
function noteToFields(note: NoteContent): FrontmatterFields {
|
||||
return {
|
||||
title: note.title,
|
||||
description: note.description ?? "",
|
||||
topic: note.topic,
|
||||
status: note.status,
|
||||
lang: note.lang,
|
||||
draft: note.draft,
|
||||
order: note.order,
|
||||
}
|
||||
}
|
||||
210
src/lib/mock-data.ts
Normal file
210
src/lib/mock-data.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import type { NoteContent, Topic } from "./types"
|
||||
|
||||
export const MOCK_NOTES: NoteContent[] = [
|
||||
{
|
||||
slug: "psychologie/flow-theorie",
|
||||
title: "Flow-Theorie",
|
||||
description: "Csikszentmihalyi's Konzept des optimalen Erlebens",
|
||||
topic: "psychologie",
|
||||
status: "evergreen",
|
||||
lang: "de",
|
||||
content: `# Flow-Theorie
|
||||
|
||||
Die **Flow-Theorie** wurde von Mihaly Csikszentmihalyi in den 1970er Jahren entwickelt. Sie beschreibt einen mentalen Zustand vollständiger Vertiefung und Aufgehens in einer Tätigkeit.
|
||||
|
||||
## Kernkonzept
|
||||
|
||||
Flow entsteht wenn Herausforderung und Fähigkeit in einem *optimalen Verhältnis* stehen — weder zu leicht (Langeweile) noch zu schwer (Angst und Überforderung).
|
||||
|
||||
## Merkmale des Flow-Zustands
|
||||
|
||||
- Vollständige Konzentration auf die aktuelle Aufgabe
|
||||
- Verlust des Zeitgefühls — Stunden vergehen wie Minuten
|
||||
- Effortless action — Handeln fühlt sich mühelos an
|
||||
- Klares Ziel und unmittelbares Feedback
|
||||
- Verschmelzung von Handlung und Bewusstsein
|
||||
|
||||
## Flow-Kanal
|
||||
|
||||
Csikszentmihalyi beschreibt einen "Flow-Kanal" zwischen Angst und Langeweile. Optimale Erfahrungen entstehen genau in diesem Kanal.
|
||||
|
||||
\`\`\`
|
||||
Hohe Herausforderung
|
||||
│ Flow-Kanal
|
||||
│ ╱
|
||||
│ ╱
|
||||
│╱
|
||||
└────────────────
|
||||
Hohe Fähigkeit
|
||||
\`\`\`
|
||||
|
||||
## Anwendung
|
||||
|
||||
Flow lässt sich durch bewusste [[Umgebungsgestaltung]] und Praktiken wie [[Deep Work]] fördern. Entscheidend ist die Kalibrierung von Schwierigkeit und eigenem Können.
|
||||
|
||||
## Verwandte Konzepte
|
||||
|
||||
- [[Intrinsische Motivation]]
|
||||
- [[Zustand des Nicht-Handelns]] (Wu Wei)
|
||||
- [[Prokrastination]] — das Gegenteil von Flow`,
|
||||
},
|
||||
{
|
||||
slug: "psychologie/prokrastination",
|
||||
title: "Prokrastination",
|
||||
description: "Das Aufschieben von Aufgaben und seine psychologischen Wurzeln",
|
||||
topic: "psychologie",
|
||||
status: "budding",
|
||||
lang: "de",
|
||||
content: `# Prokrastination
|
||||
|
||||
Prokrastination ist mehr als Faulheit — sie ist primär eine **emotionale Regulationsstrategie**.
|
||||
|
||||
## Psychologische Wurzeln
|
||||
|
||||
Menschen schieben nicht auf weil sie faul sind, sondern weil sie versuchen negative Gefühle zu vermeiden die mit einer Aufgabe verbunden sind.
|
||||
|
||||
### Häufige Auslöser
|
||||
|
||||
- **Perfektionismus** — Angst vor unzureichenden Ergebnissen
|
||||
- **Aufgaben-Aversion** — die Aufgabe selbst fühlt sich unangenehm an
|
||||
- **Schwache Selbstregulation** — mangelnde Impulskontrolle
|
||||
- **Vage Ziele** — fehlende Klarheit über den nächsten Schritt
|
||||
|
||||
## Strategien
|
||||
|
||||
1. Implementation Intentions — "Wenn X dann Y" Pläne
|
||||
2. Timeboxing — feste Zeitblöcke für unangenehme Aufgaben
|
||||
3. Aufgaben verkleinern — [[Next Actions]] statt große Projekte`,
|
||||
},
|
||||
{
|
||||
slug: "psychologie/kognitive-verzerrungen",
|
||||
title: "Kognitive Verzerrungen",
|
||||
description: "Systematische Denkfehler und wie sie unser Urteil trüben",
|
||||
topic: "psychologie",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
content: `# Kognitive Verzerrungen
|
||||
|
||||
Kognitive Verzerrungen sind systematische Muster irrationalen Denkens.
|
||||
|
||||
## Bekannte Verzerrungen
|
||||
|
||||
- **Bestätigungsfehler** — wir suchen Informationen die unsere Ansichten bestätigen
|
||||
- **Verfügbarkeitsheuristik** — leicht erinnerbare Ereignisse werden überschätzt
|
||||
- **Dunning-Kruger-Effekt** — inkompetente Menschen überschätzen ihre Fähigkeiten`,
|
||||
},
|
||||
{
|
||||
slug: "biosysteme/brs-konzept",
|
||||
title: "BRS-Konzept",
|
||||
description: "Biologische Rückkopplungssysteme in komplexen Ökosystemen",
|
||||
topic: "biosysteme",
|
||||
status: "evergreen",
|
||||
lang: "de",
|
||||
content: `# BRS-Konzept
|
||||
|
||||
Das BRS-Konzept beschreibt **Biologische Rückkopplungssysteme** — selbstregulierende Mechanismen in komplexen Ökosystemen.
|
||||
|
||||
## Grundprinzip
|
||||
|
||||
Ein Rückkopplungssystem reagiert auf seinen eigenen Output und passt sein Verhalten entsprechend an. In biologischen Systemen sorgt dies für Homöostase.
|
||||
|
||||
## Positive vs. Negative Rückkopplung
|
||||
|
||||
| Typ | Wirkung | Beispiel |
|
||||
|-----|---------|---------|
|
||||
| Negativ | Stabilisierend | Körpertemperatur |
|
||||
| Positiv | Verstärkend | Blutgerinnung |`,
|
||||
},
|
||||
{
|
||||
slug: "biosysteme/hydroponik",
|
||||
title: "Hydroponik",
|
||||
description: "Pflanzenanbau ohne Erde — Grundlagen und Systeme",
|
||||
topic: "biosysteme",
|
||||
status: "budding",
|
||||
lang: "de",
|
||||
content: `# Hydroponik
|
||||
|
||||
Hydroponik ist der Anbau von Pflanzen in Nährlösung ohne Erde.
|
||||
|
||||
## Vorteile
|
||||
|
||||
- Bis zu 90% weniger Wasserverbrauch
|
||||
- Schnelleres Wachstum durch optimale Nährstoffversorgung
|
||||
- Unabhängig von Bodenbeschaffenheit und Klima`,
|
||||
},
|
||||
{
|
||||
slug: "quantenmechanik/superposition",
|
||||
title: "Superposition",
|
||||
description: "Das Prinzip der Superposition in der Quantenmechanik",
|
||||
topic: "quantenmechanik",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
content: `# Superposition
|
||||
|
||||
Das **Superpositionsprinzip** besagt, dass ein Quantensystem sich gleichzeitig in mehreren Zuständen befinden kann, bis eine Messung stattfindet.
|
||||
|
||||
## Mathematische Darstellung
|
||||
|
||||
Ein Qubit in Superposition:
|
||||
|
||||
$$|\\psi\\rangle = \\alpha|0\\rangle + \\beta|1\\rangle$$
|
||||
|
||||
wobei $|\\alpha|^2 + |\\beta|^2 = 1$.
|
||||
|
||||
## Schrödinger's Katze
|
||||
|
||||
Das bekannteste Gedankenexperiment zur Superposition — eine Katze ist gleichzeitig lebendig und tot, solange die Box geschlossen bleibt.`,
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_DRAFTS: NoteContent[] = [
|
||||
{
|
||||
slug: "_drafts/neue-gedanken",
|
||||
title: "Neue Gedanken",
|
||||
description: "",
|
||||
topic: "_drafts",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
draftStage: "draft",
|
||||
content: `# Neue Gedanken
|
||||
|
||||
Idee: Verbindung zwischen [[Flow-Theorie]] und Kreativität untersuchen.
|
||||
|
||||
Was passiert im Gehirn während Flow-Zuständen? Gibt es Parallelen zur Meditation?
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- Kann man Flow künstlich induzieren?
|
||||
- Rolle des Default Mode Network`,
|
||||
},
|
||||
{
|
||||
slug: "_drafts/notiz-ohne-titel",
|
||||
title: "Unbenannte Notiz",
|
||||
description: "",
|
||||
topic: "_drafts",
|
||||
status: "seed",
|
||||
lang: "de",
|
||||
draftStage: "draft",
|
||||
content: `# Unbenannte Notiz
|
||||
|
||||
Schnelle Notiz — noch kein Thema gefunden.`,
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_TOPICS: Topic[] = [
|
||||
{
|
||||
slug: "psychologie",
|
||||
title: "Psychologie",
|
||||
notes: MOCK_NOTES.filter((n) => n.topic === "psychologie"),
|
||||
},
|
||||
{
|
||||
slug: "biosysteme",
|
||||
title: "Biosysteme",
|
||||
notes: MOCK_NOTES.filter((n) => n.topic === "biosysteme"),
|
||||
},
|
||||
{
|
||||
slug: "quantenmechanik",
|
||||
title: "Quantenmechanik",
|
||||
notes: MOCK_NOTES.filter((n) => n.topic === "quantenmechanik"),
|
||||
},
|
||||
]
|
||||
35
src/lib/types.ts
Normal file
35
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
export type NoteStatus = "seed" | "budding" | "evergreen"
|
||||
export type DraftStage = "draft" | "in-review" | "live"
|
||||
export type Lang = "de" | "en"
|
||||
|
||||
export interface NoteMeta {
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
topic: string
|
||||
status: NoteStatus
|
||||
lang: Lang
|
||||
draft?: boolean
|
||||
draftStage?: DraftStage
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface NoteContent extends NoteMeta {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
slug: string
|
||||
title: string
|
||||
notes: NoteMeta[]
|
||||
subTopics?: Topic[]
|
||||
}
|
||||
|
||||
export interface StudioConfig {
|
||||
auth: "token" | "authentik" | "both"
|
||||
name: string
|
||||
lang: string
|
||||
allow_delete: boolean
|
||||
allow_push: boolean
|
||||
allow_rebuild: boolean
|
||||
}
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue