noz-studio/src/app/login/page.tsx
kryptomrx 3f9553cff4 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
2026-05-18 16:05:11 +02:00

216 lines
8.2 KiB
TypeScript

"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>
)
}