Compare commits
No commits in common. "177f3d5ab99a128370e4ca0e0ae38c8edcc3969f" and "4cd4ee208c1b2eaf3adb07bb99d2182bcc855221" have entirely different histories.
177f3d5ab9
...
4cd4ee208c
15 changed files with 348 additions and 339 deletions
|
|
@ -1,9 +0,0 @@
|
||||||
.git
|
|
||||||
node_modules
|
|
||||||
.next
|
|
||||||
.env.local
|
|
||||||
.env*.local
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
tsconfig.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
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
|
||||||
23
Dockerfile
23
Dockerfile
|
|
@ -1,23 +0,0 @@
|
||||||
FROM node:22-alpine AS base
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
RUN node_modules/.bin/next build
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3001
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
EXPOSE 3001
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
66
README.md
66
README.md
|
|
@ -1,66 +0,0 @@
|
||||||
# noz-studio
|
|
||||||
|
|
||||||
A web editor PWA for [noz](https://github.com/noz-lab/noz) — write and manage notes from any device, even without the CLI.
|
|
||||||
|
|
||||||
> **Personal use.** Built as part of my own noz setup — open source, but things change without notice.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **CodeMirror 6** editor with Markdown syntax highlighting
|
|
||||||
- **Live preview** — rendered note side by side with the editor
|
|
||||||
- **Wikilinks** — `[[Note Title]]` autocomplete and resolution
|
|
||||||
- **Diagram canvas** — visual editor for `_diagram.toml` folder diagrams
|
|
||||||
- **Full-text search** — search your garden from the sidebar
|
|
||||||
- **PWA** — installable, works offline for reading cached notes
|
|
||||||
- **Dark / light mode**
|
|
||||||
- **Multilingual** (de / en)
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
noz-studio connects to a running noz instance via the CLI API. You need a token configured in your noz server's `.env.local`:
|
|
||||||
|
|
||||||
```
|
|
||||||
NOZ_CLI_TOKEN=your-secret-token
|
|
||||||
```
|
|
||||||
|
|
||||||
And in `noosphere.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[studio]
|
|
||||||
auth = "token" # or "authentik" or "both"
|
|
||||||
allow_push = true
|
|
||||||
```
|
|
||||||
|
|
||||||
Then set the allowed origin for CORS in `.env.local` on your noz server:
|
|
||||||
|
|
||||||
```
|
|
||||||
NOZ_STUDIO_ORIGIN=https://studio.your-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # starts on port 3001
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:3001`, enter your noz server URL and token.
|
|
||||||
|
|
||||||
For local development, you can prefill credentials via environment variables:
|
|
||||||
|
|
||||||
```
|
|
||||||
NEXT_PUBLIC_DEV_SERVER=http://localhost:3000
|
|
||||||
NEXT_PUBLIC_DEV_TOKEN=your-token
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
noz-studio is a standard Next.js app — deploy anywhere Next.js runs (Vercel, Docker, self-hosted).
|
|
||||||
|
|
||||||
The `infra/` folder in [noz](https://github.com/noz-lab/noz) contains a Traefik config for self-hosting behind a reverse proxy.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[AGPL-3.0-only](LICENSE)
|
|
||||||
|
|
||||||
For commercial licensing (proprietary use without copyleft), open an issue.
|
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
|
||||||
headers: async () => [
|
headers: async () => [
|
||||||
{
|
{
|
||||||
source: "/(.*)",
|
source: "/(.*)",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
"name": "noz-studio",
|
"name": "noz-studio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Web editor PWA for noz — CodeMirror 6, live preview, wikilinks, diagram canvas and full offline support.",
|
"description": "Web editor PWA for noz — CodeMirror 6, live preview, wikilinks, diagram canvas and full offline support.",
|
||||||
"author": "Bruno Deanoz",
|
"author": "Bruno Deanoz <bruno.deanoz@protonmail.com>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"homepage": "https://noz.dev/studio",
|
"homepage": "https://noz.dev/studio",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/noz-lab/noz-studio.git"
|
"url": "https://git.kryptomrx.de/noz/noz-studio.git"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.36.3",
|
"@codemirror/view": "^6.36.3",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"gray-matter": "^4.0.3",
|
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.47",
|
"katex": "^0.16.47",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,6 @@ importers:
|
||||||
'@lezer/highlight':
|
'@lezer/highlight':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
gray-matter:
|
|
||||||
specifier: ^4.0.3
|
|
||||||
version: 4.0.3
|
|
||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.11.1
|
specifier: ^11.11.1
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
|
|
@ -532,9 +529,6 @@ packages:
|
||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
argparse@1.0.10:
|
|
||||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.30:
|
baseline-browser-mapping@2.10.30:
|
||||||
resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==}
|
resolution: {integrity: sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
@ -564,46 +558,21 @@ packages:
|
||||||
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
esprima@4.0.1:
|
|
||||||
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
|
||||||
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
gray-matter@4.0.3:
|
|
||||||
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
|
|
||||||
engines: {node: '>=6.0'}
|
|
||||||
|
|
||||||
highlight.js@11.11.1:
|
highlight.js@11.11.1:
|
||||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
is-extendable@0.1.1:
|
|
||||||
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
jiti@2.7.0:
|
jiti@2.7.0:
|
||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
js-yaml@3.14.2:
|
|
||||||
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
katex@0.16.47:
|
katex@0.16.47:
|
||||||
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
kind-of@6.0.3:
|
|
||||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
@ -731,10 +700,6 @@ packages:
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
section-matter@1.0.0:
|
|
||||||
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
|
|
||||||
engines: {node: '>=4'}
|
|
||||||
|
|
||||||
semver@7.8.0:
|
semver@7.8.0:
|
||||||
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
|
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -752,13 +717,6 @@ packages:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
sprintf-js@1.0.3:
|
|
||||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
|
||||||
|
|
||||||
strip-bom-string@1.0.0:
|
|
||||||
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
style-mod@4.1.3:
|
style-mod@4.1.3:
|
||||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||||
|
|
||||||
|
|
@ -1372,10 +1330,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
argparse@1.0.10:
|
|
||||||
dependencies:
|
|
||||||
sprintf-js: 1.0.3
|
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.30: {}
|
baseline-browser-mapping@2.10.30: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001793: {}
|
caniuse-lite@1.0.30001793: {}
|
||||||
|
|
@ -1395,38 +1349,16 @@ snapshots:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
|
|
||||||
esprima@4.0.1: {}
|
|
||||||
|
|
||||||
extend-shallow@2.0.1:
|
|
||||||
dependencies:
|
|
||||||
is-extendable: 0.1.1
|
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
gray-matter@4.0.3:
|
|
||||||
dependencies:
|
|
||||||
js-yaml: 3.14.2
|
|
||||||
kind-of: 6.0.3
|
|
||||||
section-matter: 1.0.0
|
|
||||||
strip-bom-string: 1.0.0
|
|
||||||
|
|
||||||
highlight.js@11.11.1: {}
|
highlight.js@11.11.1: {}
|
||||||
|
|
||||||
is-extendable@0.1.1: {}
|
|
||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
js-yaml@3.14.2:
|
|
||||||
dependencies:
|
|
||||||
argparse: 1.0.10
|
|
||||||
esprima: 4.0.1
|
|
||||||
|
|
||||||
katex@0.16.47:
|
katex@0.16.47:
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 8.3.0
|
commander: 8.3.0
|
||||||
|
|
||||||
kind-of@6.0.3: {}
|
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -1531,11 +1463,6 @@ snapshots:
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
section-matter@1.0.0:
|
|
||||||
dependencies:
|
|
||||||
extend-shallow: 2.0.1
|
|
||||||
kind-of: 6.0.3
|
|
||||||
|
|
||||||
semver@7.8.0:
|
semver@7.8.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -1575,10 +1502,6 @@ snapshots:
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
sprintf-js@1.0.3: {}
|
|
||||||
|
|
||||||
strip-bom-string@1.0.0: {}
|
|
||||||
|
|
||||||
style-mod@4.1.3: {}
|
style-mod@4.1.3: {}
|
||||||
|
|
||||||
styled-jsx@5.1.6(react@19.2.4):
|
styled-jsx@5.1.6(react@19.2.4):
|
||||||
|
|
|
||||||
|
|
@ -2,56 +2,24 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import { setCredentials, isAuthenticated } from "@/lib/auth"
|
||||||
setCredentials, isAuthenticated,
|
import { fetchStudioConfig, testToken, NozApiError } from "@/lib/api"
|
||||||
setPendingServerUrl, getPendingServerUrl, clearPendingServerUrl,
|
|
||||||
} from "@/lib/auth"
|
|
||||||
import { fetchStudioConfig, testToken, NozApiError, type StudioServerConfig } from "@/lib/api"
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const devServer = process.env.NEXT_PUBLIC_DEV_SERVER ?? ""
|
const devServer = process.env.NEXT_PUBLIC_DEV_SERVER ?? ""
|
||||||
const devToken = process.env.NEXT_PUBLIC_DEV_TOKEN ?? ""
|
const devToken = process.env.NEXT_PUBLIC_DEV_TOKEN ?? ""
|
||||||
|
|
||||||
const [url, setUrl] = useState(devServer || "")
|
const [url, setUrl] = useState(devServer || "https://noz.kryptomnrx.de")
|
||||||
const [token, setToken] = useState(devToken)
|
const [token, setToken] = useState(devToken)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [serverConf, setServerConf] = useState<StudioServerConfig | null>(null)
|
|
||||||
|
|
||||||
// Fixture-Modus oder bereits angemeldet → direkt zum Editor
|
// Fixture-Modus oder bereits angemeldet → direkt zum Editor
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated()) router.replace("/editor")
|
if (isAuthenticated()) router.replace("/editor")
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
// Authentik redirect: detect #token= in URL hash on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
const hash = window.location.hash
|
|
||||||
if (!hash.startsWith("#token=")) return
|
|
||||||
|
|
||||||
const incoming = decodeURIComponent(hash.slice(7))
|
|
||||||
const savedUrl = getPendingServerUrl()
|
|
||||||
if (!incoming || !savedUrl) return
|
|
||||||
|
|
||||||
clearPendingServerUrl()
|
|
||||||
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
|
||||||
setCredentials(savedUrl, incoming)
|
|
||||||
router.replace("/editor")
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
// Fetch server config whenever URL changes (debounced)
|
|
||||||
useEffect(() => {
|
|
||||||
const trimmed = url.trim()
|
|
||||||
if (!trimmed) { setServerConf(null); return }
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
fetchStudioConfig(trimmed)
|
|
||||||
.then(cfg => setServerConf(cfg))
|
|
||||||
.catch(() => setServerConf(null))
|
|
||||||
}, 500)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}, [url])
|
|
||||||
|
|
||||||
async function handleConnect(e: React.FormEvent) {
|
async function handleConnect(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!url.trim() || !token.trim()) {
|
if (!url.trim() || !token.trim()) {
|
||||||
|
|
@ -63,8 +31,13 @@ export default function LoginPage() {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Schritt 1: Server als noz-Instanz verifizieren
|
||||||
await fetchStudioConfig(url.trim())
|
await fetchStudioConfig(url.trim())
|
||||||
|
|
||||||
|
// Schritt 2: Token validieren
|
||||||
await testToken(url.trim(), token.trim())
|
await testToken(url.trim(), token.trim())
|
||||||
|
|
||||||
|
// Erfolg: Credentials speichern + weiterleiten
|
||||||
setCredentials(url.trim(), token.trim())
|
setCredentials(url.trim(), token.trim())
|
||||||
router.push("/editor")
|
router.push("/editor")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -77,17 +50,6 @@ export default function LoginPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAuthentik() {
|
|
||||||
const trimmed = url.trim()
|
|
||||||
if (!trimmed) return
|
|
||||||
setPendingServerUrl(trimmed)
|
|
||||||
window.location.href = `${trimmed.replace(/\/$/, "")}/api/studio/auth/authentik`
|
|
||||||
}
|
|
||||||
|
|
||||||
const showToken = !serverConf || serverConf.auth === "token" || serverConf.auth === "both"
|
|
||||||
const showAuthentik = !serverConf || serverConf.auth === "authentik" || serverConf.auth === "both"
|
|
||||||
const authentikEnabled = !!url.trim() && !loading
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background relative overflow-hidden">
|
<div className="min-h-screen flex items-center justify-center bg-background relative overflow-hidden">
|
||||||
{/* Nebula glows */}
|
{/* Nebula glows */}
|
||||||
|
|
@ -123,37 +85,34 @@ export default function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted border border-border rounded-xl overflow-hidden">
|
<form onSubmit={handleConnect}>
|
||||||
{/* Fehlermeldung */}
|
<div className="bg-muted border border-border rounded-xl overflow-hidden">
|
||||||
{error && (
|
<div className="p-6 space-y-4">
|
||||||
<div className="mx-6 mt-4 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Server URL field — always shown */}
|
{/* Fehlermeldung */}
|
||||||
<div className="px-6 pt-5 pb-4 space-y-4">
|
{error && (
|
||||||
<div className="space-y-1.5">
|
<div className="flex items-start gap-2.5 px-3 py-2.5 rounded-lg text-xs font-mono"
|
||||||
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
|
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)" }}>
|
||||||
Server
|
<ErrorIcon />
|
||||||
</label>
|
<span>{error}</span>
|
||||||
<input
|
</div>
|
||||||
type="url"
|
)}
|
||||||
value={url}
|
|
||||||
onChange={(e) => { setUrl(e.target.value); setError(null) }}
|
<div className="space-y-1.5">
|
||||||
placeholder="https://noz.example.com"
|
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
|
||||||
disabled={loading}
|
Server
|
||||||
required
|
</label>
|
||||||
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"
|
<input
|
||||||
/>
|
type="url"
|
||||||
</div>
|
value={url}
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
{/* Token login */}
|
|
||||||
{showToken && (
|
|
||||||
<form onSubmit={handleConnect} className="px-6 pb-5 space-y-4">
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
|
<label className="text-[10px] font-mono text-muted-fg/60 uppercase tracking-widest">
|
||||||
Token
|
Token
|
||||||
|
|
@ -183,33 +142,29 @@ export default function LoginPage() {
|
||||||
"Verbinden"
|
"Verbinden"
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Divider — only shown when both methods are available */}
|
{/* Divider */}
|
||||||
{showToken && showAuthentik && (
|
|
||||||
<div className="flex items-center gap-3 px-6 pb-1">
|
<div className="flex items-center gap-3 px-6 pb-1">
|
||||||
<div className="flex-1 h-px bg-border" />
|
<div className="flex-1 h-px bg-border" />
|
||||||
<span className="text-[11px] text-muted-fg/40 font-mono">oder</span>
|
<span className="text-[11px] text-muted-fg/40 font-mono">oder</span>
|
||||||
<div className="flex-1 h-px bg-border" />
|
<div className="flex-1 h-px bg-border" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Authentik SSO */}
|
{/* Authentik — noch nicht implementiert */}
|
||||||
{showAuthentik && (
|
|
||||||
<div className="p-4 pt-3">
|
<div className="p-4 pt-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!authentikEnabled}
|
disabled
|
||||||
onClick={handleAuthentik}
|
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 transition-all active:scale-[0.98] disabled:opacity-40 disabled:cursor-not-allowed enabled:hover:border-accent enabled:hover:text-foreground text-muted-fg/60"
|
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 />
|
<AuthentikIcon />
|
||||||
<span>Mit Authentik anmelden</span>
|
<span>Mit Authentik anmelden</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-[11px] text-muted-fg/30 mt-5 font-mono">
|
<p className="text-center text-[11px] text-muted-fg/30 mt-5 font-mono">
|
||||||
Token aus{" "}
|
Token aus{" "}
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ export default function Preview({ content, className, notes, onSelectNote }: Pro
|
||||||
|
|
||||||
// Toast for unresolvable wikilinks
|
// Toast for unresolvable wikilinks
|
||||||
const [toast, setToast] = useState<string | null>(null)
|
const [toast, setToast] = useState<string | null>(null)
|
||||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
const toastTimer = useRef<ReturnType<typeof setTimeout>>()
|
||||||
const showToast = (msg: string) => {
|
const showToast = (msg: string) => {
|
||||||
setToast(msg)
|
setToast(msg)
|
||||||
clearTimeout(toastTimer.current)
|
clearTimeout(toastTimer.current)
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,10 @@ export function useNotes(): UseNotesReturn {
|
||||||
if (e instanceof NozApiError && e.status === 404) {
|
if (e instanceof NozApiError && e.status === 404) {
|
||||||
// Note was deleted externally — remove from local state, then refresh list
|
// Note was deleted externally — remove from local state, then refresh list
|
||||||
cacheRef.current.delete(slug)
|
cacheRef.current.delete(slug)
|
||||||
setTopics(prev => filterTopicTree(prev, n => n.slug !== 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))
|
setDrafts(prev => prev.filter(n => n.slug !== slug))
|
||||||
setActiveNote(null)
|
setActiveNote(null)
|
||||||
// Reload full list so sidebar reflects server state
|
// Reload full list so sidebar reflects server state
|
||||||
|
|
@ -225,7 +228,10 @@ export function useNotes(): UseNotesReturn {
|
||||||
const deleteNote = useCallback(async (slug: string) => {
|
const deleteNote = useCallback(async (slug: string) => {
|
||||||
if (!DEV) await deleteNoteApi(slug)
|
if (!DEV) await deleteNoteApi(slug)
|
||||||
cacheRef.current.delete(slug)
|
cacheRef.current.delete(slug)
|
||||||
setTopics(prev => filterTopicTree(prev, n => n.slug !== 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))
|
setDrafts(prev => prev.filter(n => n.slug !== slug))
|
||||||
setActiveNote(prev => {
|
setActiveNote(prev => {
|
||||||
if (prev?.slug !== slug) return prev
|
if (prev?.slug !== slug) return prev
|
||||||
|
|
@ -250,7 +256,10 @@ export function useNotes(): UseNotesReturn {
|
||||||
if (DEV) {
|
if (DEV) {
|
||||||
const cached = cacheRef.current.get(slug)
|
const cached = cacheRef.current.get(slug)
|
||||||
if (cached) cacheRef.current.set(slug, { ...cached, title })
|
if (cached) cacheRef.current.set(slug, { ...cached, title })
|
||||||
setTopics(prev => mapTopicNotes(prev, n => n.slug === slug ? { ...n, title } : n))
|
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))
|
setDrafts(prev => prev.map(n => n.slug === slug ? { ...n, title } : n))
|
||||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
|
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
|
||||||
return
|
return
|
||||||
|
|
@ -258,7 +267,10 @@ export function useNotes(): UseNotesReturn {
|
||||||
await patchNoteApi(slug, { title })
|
await patchNoteApi(slug, { title })
|
||||||
const cached = cacheRef.current.get(slug)
|
const cached = cacheRef.current.get(slug)
|
||||||
if (cached) cacheRef.current.set(slug, { ...cached, title })
|
if (cached) cacheRef.current.set(slug, { ...cached, title })
|
||||||
setTopics(prev => mapTopicNotes(prev, n => n.slug === slug ? { ...n, title } : n))
|
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))
|
setDrafts(prev => prev.map(n => n.slug === slug ? { ...n, title } : n))
|
||||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
|
setActiveNote(prev => prev?.slug === slug ? { ...prev, title } : prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -267,7 +279,7 @@ export function useNotes(): UseNotesReturn {
|
||||||
|
|
||||||
const toggleDraft = useCallback(async (slug: string) => {
|
const toggleDraft = useCallback(async (slug: string) => {
|
||||||
const meta =
|
const meta =
|
||||||
flattenNotes(topicsRef.current).find(n => n.slug === slug) ??
|
topicsRef.current.flatMap(t => t.notes).find(n => n.slug === slug) ??
|
||||||
draftsRef.current.find(n => n.slug === slug)
|
draftsRef.current.find(n => n.slug === slug)
|
||||||
if (!meta || meta.draftStage === "draft") return // can't toggle _drafts/ notes here
|
if (!meta || meta.draftStage === "draft") return // can't toggle _drafts/ notes here
|
||||||
|
|
||||||
|
|
@ -279,7 +291,7 @@ export function useNotes(): UseNotesReturn {
|
||||||
|
|
||||||
const cached = cacheRef.current.get(slug)
|
const cached = cacheRef.current.get(slug)
|
||||||
if (cached) cacheRef.current.set(slug, { ...cached, draft: nextDraft, draftStage: nextStage })
|
if (cached) cacheRef.current.set(slug, { ...cached, draft: nextDraft, draftStage: nextStage })
|
||||||
setTopics(prev => mapTopicNotes(prev, update))
|
setTopics(prev => prev.map(t => ({ ...t, notes: t.notes.map(update) })))
|
||||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, draft: nextDraft, draftStage: nextStage } : prev)
|
setActiveNote(prev => prev?.slug === slug ? { ...prev, draft: nextDraft, draftStage: nextStage } : prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -326,7 +338,10 @@ export function useNotes(): UseNotesReturn {
|
||||||
const cached = cacheRef.current.get(slug)
|
const cached = cacheRef.current.get(slug)
|
||||||
if (cached) cacheRef.current.set(slug, { ...cached, ...updates })
|
if (cached) cacheRef.current.set(slug, { ...cached, ...updates })
|
||||||
setActiveNote(prev => prev?.slug === slug ? { ...prev, ...updates } : prev)
|
setActiveNote(prev => prev?.slug === slug ? { ...prev, ...updates } : prev)
|
||||||
setTopics(prev => mapTopicNotes(prev, n => n.slug === slug ? { ...n, ...updates } : n))
|
setTopics(prev => prev.map(t => ({
|
||||||
|
...t,
|
||||||
|
notes: t.notes.map(n => n.slug === slug ? { ...n, ...updates } : n),
|
||||||
|
})))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/* ── loadNotes ──────────────────────────────────────────────────── */
|
/* ── loadNotes ──────────────────────────────────────────────────── */
|
||||||
|
|
@ -344,7 +359,8 @@ export function useNotes(): UseNotesReturn {
|
||||||
topicsRef.current = newTopics
|
topicsRef.current = newTopics
|
||||||
draftsRef.current = newDrafts
|
draftsRef.current = newDrafts
|
||||||
|
|
||||||
const firstSlug = flattenNotes(newTopics)[0]?.slug ?? newDrafts[0]?.slug
|
const firstSlug = newTopics[0]?.notes[0]?.slug
|
||||||
|
?? newTopics[0]?.subTopics?.[0]?.notes[0]?.slug
|
||||||
if (firstSlug) selectNote(firstSlug)
|
if (firstSlug) selectNote(firstSlug)
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
|
|
@ -379,26 +395,6 @@ function findNoteMeta(topics: Topic[], slug: string): NoteMeta | undefined {
|
||||||
return flattenNotes(topics).find(n => n.slug === slug)
|
return flattenNotes(topics).find(n => n.slug === slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a function over every note in the tree, recursing into subTopics. */
|
|
||||||
function mapTopicNotes(topics: Topic[], fn: (n: NoteMeta) => NoteMeta): Topic[] {
|
|
||||||
return topics.map(t => ({
|
|
||||||
...t,
|
|
||||||
notes: t.notes.map(fn),
|
|
||||||
subTopics: t.subTopics ? mapTopicNotes(t.subTopics, fn) : undefined,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove notes matching predicate, dropping empty topics (preserves topics that still have subTopics). */
|
|
||||||
function filterTopicTree(topics: Topic[], keep: (n: NoteMeta) => boolean): Topic[] {
|
|
||||||
return topics
|
|
||||||
.map(t => ({
|
|
||||||
...t,
|
|
||||||
notes: t.notes.filter(keep),
|
|
||||||
subTopics: t.subTopics ? filterTopicTree(t.subTopics, keep) : undefined,
|
|
||||||
}))
|
|
||||||
.filter(t => t.notes.length > 0 || (t.subTopics?.length ?? 0) > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── buildTopicTree ─────────────────────────────────────────────────────────
|
/* ── buildTopicTree ─────────────────────────────────────────────────────────
|
||||||
Baut den Ordner-Baum aus Slug-Pfaden auf, nicht aus dem topic-Frontmatter-Feld.
|
Baut den Ordner-Baum aus Slug-Pfaden auf, nicht aus dem topic-Frontmatter-Feld.
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,13 @@ export async function patchNote(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
export async function createNote(
|
||||||
title: string,
|
title: string,
|
||||||
topic?: string,
|
topic?: string,
|
||||||
|
|
|
||||||
|
|
@ -32,23 +32,3 @@ export function isAuthenticated(): boolean {
|
||||||
if (process.env.NEXT_PUBLIC_DEV_FIXTURES === "true") return true;
|
if (process.env.NEXT_PUBLIC_DEV_FIXTURES === "true") return true;
|
||||||
return !!(getToken() && getServerUrl());
|
return !!(getToken() && getServerUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Authentik redirect helpers (localStorage — persists across navigations) ── */
|
|
||||||
|
|
||||||
const KEY_PENDING_URL = "noz_pending_url";
|
|
||||||
|
|
||||||
function ls(): Storage | null {
|
|
||||||
return typeof window !== "undefined" ? localStorage : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setPendingServerUrl(url: string): void {
|
|
||||||
ls()?.setItem(KEY_PENDING_URL, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPendingServerUrl(): string | null {
|
|
||||||
return ls()?.getItem(KEY_PENDING_URL) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearPendingServerUrl(): void {
|
|
||||||
ls()?.removeItem(KEY_PENDING_URL);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import matter from "gray-matter"
|
|
||||||
import type { NoteContent, NoteStatus, Lang } from "./types"
|
import type { NoteContent, NoteStatus, Lang } from "./types"
|
||||||
|
|
||||||
export interface FrontmatterFields {
|
export interface FrontmatterFields {
|
||||||
|
|
@ -8,7 +7,6 @@ export interface FrontmatterFields {
|
||||||
status: NoteStatus
|
status: NoteStatus
|
||||||
lang: Lang
|
lang: Lang
|
||||||
draft?: boolean
|
draft?: boolean
|
||||||
private?: boolean
|
|
||||||
order?: number
|
order?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,20 +15,32 @@ const DEFAULT: FrontmatterFields = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitContent(markdown: string): { fields: FrontmatterFields; body: string } {
|
export function splitContent(markdown: string): { fields: FrontmatterFields; body: string } {
|
||||||
const { data, content } = matter(markdown)
|
if (!markdown.startsWith("---\n")) return { fields: { ...DEFAULT }, body: markdown }
|
||||||
return {
|
|
||||||
fields: {
|
const end = markdown.indexOf("\n---\n", 4)
|
||||||
title: typeof data.title === "string" ? data.title : DEFAULT.title,
|
if (end === -1) return { fields: { ...DEFAULT }, body: markdown }
|
||||||
description: typeof data.description === "string" ? data.description : DEFAULT.description,
|
|
||||||
topic: typeof data.topic === "string" ? data.topic : DEFAULT.topic,
|
const yamlStr = markdown.slice(4, end)
|
||||||
status: typeof data.status === "string" ? data.status as NoteStatus : DEFAULT.status,
|
const body = markdown.slice(end + 5)
|
||||||
lang: typeof data.lang === "string" ? data.lang as Lang : DEFAULT.lang,
|
const fields = { ...DEFAULT }
|
||||||
draft: typeof data.draft === "boolean" ? data.draft : undefined,
|
|
||||||
private: typeof data.private === "boolean" ? data.private : undefined,
|
for (const line of yamlStr.split("\n")) {
|
||||||
order: typeof data.order === "number" ? data.order : undefined,
|
const m = line.match(/^(\w+):\s*(.*)$/)
|
||||||
},
|
if (!m) continue
|
||||||
body: content,
|
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 {
|
export function buildMarkdown(note: NoteContent | FrontmatterFields, body: string): string {
|
||||||
|
|
@ -43,9 +53,8 @@ export function buildMarkdown(note: NoteContent | FrontmatterFields, body: strin
|
||||||
`status: ${f.status}`,
|
`status: ${f.status}`,
|
||||||
`lang: ${f.lang}`,
|
`lang: ${f.lang}`,
|
||||||
]
|
]
|
||||||
if (f.draft) lines.push("draft: true")
|
if (f.draft) lines.push("draft: true")
|
||||||
if (f.private) lines.push("private: true")
|
if (f.order) lines.push(`order: ${f.order}`)
|
||||||
if (f.order) lines.push(`order: ${f.order}`)
|
|
||||||
lines.push("---", "")
|
lines.push("---", "")
|
||||||
return lines.join("\n") + body
|
return lines.join("\n") + body
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +67,6 @@ function noteToFields(note: NoteContent): FrontmatterFields {
|
||||||
status: note.status,
|
status: note.status,
|
||||||
lang: note.lang,
|
lang: note.lang,
|
||||||
draft: note.draft,
|
draft: note.draft,
|
||||||
private: note.private,
|
|
||||||
order: note.order,
|
order: note.order,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ export interface NoteMeta {
|
||||||
status: NoteStatus
|
status: NoteStatus
|
||||||
lang: Lang
|
lang: Lang
|
||||||
draft?: boolean
|
draft?: boolean
|
||||||
private?: boolean
|
|
||||||
draftStage?: DraftStage
|
draftStage?: DraftStage
|
||||||
order?: number
|
order?: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue