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
216 lines
8.2 KiB
TypeScript
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>
|
|
)
|
|
}
|