feat: add Admin Panel with JWT auth, DB settings, and integration management

Complete admin backend with login, where all integrations (weather, news,
Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead
of ENV variables. Two-layer config: ENV seeds DB on first start, then DB
is source of truth. Auto-migration system on startup.

Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service,
admin router (protected), test_connections per integration, config.py rewrite.

Frontend: react-router v6, login page, admin layout with sidebar, 8 settings
pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword),
shared IntegrationForm + TestButton components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 10:37:30 +01:00
parent 89ed0c6d0a
commit f6a42c2dd2
40 changed files with 3487 additions and 311 deletions

View file

@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import { Outlet, NavLink, useNavigate, Link } from "react-router-dom";
import {
Settings, Cloud, Newspaper, Home, ListTodo, Server, Radio,
Lock, ArrowLeft, Menu, X, LogOut, Loader2,
} from "lucide-react";
import { isAuthenticated, verifyToken, clearAuth, getUsername } from "./api";
const NAV_ITEMS = [
{ to: "general", label: "Allgemein", icon: Settings },
{ to: "weather", label: "Wetter", icon: Cloud },
{ to: "news", label: "News", icon: Newspaper },
{ to: "homeassistant", label: "Home Assistant", icon: Home },
{ to: "vikunja", label: "Vikunja", icon: ListTodo },
{ to: "unraid", label: "Unraid Server", icon: Server },
{ to: "mqtt", label: "MQTT", icon: Radio },
{ to: "password", label: "Passwort ändern", icon: Lock },
];
export default function AdminLayout() {
const navigate = useNavigate();
const [verified, setVerified] = useState(false);
const [checking, setChecking] = useState(true);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated()) {
navigate("/admin/login", { replace: true });
return;
}
verifyToken().then((ok) => {
if (!ok) {
clearAuth();
navigate("/admin/login", { replace: true });
} else {
setVerified(true);
}
setChecking(false);
});
}, [navigate]);
const handleLogout = () => {
clearAuth();
navigate("/admin/login", { replace: true });
};
if (checking) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-6 h-6 text-slate-400 animate-spin" />
</div>
);
}
if (!verified) return null;
return (
<div className="min-h-screen text-white">
{/* Mobile header */}
<header className="lg:hidden sticky top-0 z-50 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
<div className="flex items-center justify-between px-4 h-14">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 -ml-2 hover:bg-white/5 rounded-lg transition-colors">
{sidebarOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
<span className="text-sm font-semibold">Admin Panel</span>
<Link to="/" className="p-2 -mr-2 hover:bg-white/5 rounded-lg transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
</div>
</header>
<div className="flex">
{/* Sidebar */}
<aside className={`
fixed inset-y-0 left-0 z-40 w-64 border-r border-white/[0.04] bg-slate-950/95 backdrop-blur-lg
transform transition-transform duration-200 ease-in-out
lg:relative lg:translate-x-0 lg:block
${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}>
<div className="flex flex-col h-full">
{/* Sidebar header */}
<div className="hidden lg:flex items-center justify-between px-5 h-16 border-b border-white/[0.04]">
<h2 className="text-sm font-bold tracking-tight">Admin Panel</h2>
<Link
to="/"
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Dashboard
</Link>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{NAV_ITEMS.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={() => setSidebarOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150 ${
isActive
? "bg-white/10 text-white shadow-sm shadow-black/20"
: "text-slate-400 hover:text-white hover:bg-white/5"
}`
}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{label}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="p-4 border-t border-white/[0.04]">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 truncate">{getUsername()}</span>
<button
onClick={handleLogout}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-red-400 transition-colors"
>
<LogOut className="w-3.5 h-3.5" />
Logout
</button>
</div>
</div>
</div>
</aside>
{/* Overlay for mobile sidebar */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Main content */}
<main className="flex-1 min-h-screen lg:min-h-[calc(100vh)]">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Outlet />
</div>
</main>
</div>
</div>
);
}

113
web/src/admin/LoginPage.tsx Normal file
View file

@ -0,0 +1,113 @@
import { useState, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Lock, User, AlertCircle, Loader2 } from "lucide-react";
import { login, isAuthenticated } from "./api";
export default function LoginPage() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// Redirect if already logged in
if (isAuthenticated()) {
navigate("/admin", { replace: true });
return null;
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(username, password);
navigate("/admin", { replace: true });
} catch (err: any) {
setError(err.message || "Login fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{/* Logo / Title */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-br from-cyan-500/20 to-blue-500/20 border border-white/[0.08] mb-4">
<Lock className="w-6 h-6 text-cyan-400" />
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Daily Briefing</h1>
<p className="text-sm text-slate-500 mt-1">Admin Panel</p>
</div>
{/* Login Card */}
<div className="glass-card p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
<div>
<label htmlFor="username" className="block text-xs font-medium text-slate-400 mb-1.5">
Benutzername
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
required
autoFocus
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-xs font-medium text-slate-400 mb-1.5">
Passwort
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
</div>
</div>
<button
type="submit"
disabled={loading || !username || !password}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Anmelden"
)}
</button>
</form>
</div>
<p className="text-center text-[10px] text-slate-700 mt-6">
Daily Briefing v2.1 Admin
</p>
</div>
</div>
);
}

218
web/src/admin/api.ts Normal file
View file

@ -0,0 +1,218 @@
/**
* Admin API client with JWT token management.
* Stores the token in localStorage and automatically attaches it to requests.
*/
const API_BASE = "/api";
const TOKEN_KEY = "admin_token";
const USERNAME_KEY = "admin_user";
// ---------------------------------------------------------------------------
// Token Management
// ---------------------------------------------------------------------------
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getUsername(): string | null {
return localStorage.getItem(USERNAME_KEY);
}
export function setAuth(token: string, username: string): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USERNAME_KEY, username);
}
export function clearAuth(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USERNAME_KEY);
}
export function isAuthenticated(): boolean {
return !!getToken();
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
async function authFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401 || res.status === 403) {
clearAuth();
window.location.href = "/admin/login";
throw new Error("Sitzung abgelaufen");
}
return res;
}
async function fetchJSON<T>(path: string): Promise<T> {
const res = await authFetch(path);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
async function putJSON<T>(path: string, data: unknown): Promise<T> {
const res = await authFetch(path, {
method: "PUT",
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
async function postJSON<T>(path: string, data?: unknown): Promise<T> {
const res = await authFetch(path, {
method: "POST",
body: data ? JSON.stringify(data) : undefined,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
async function deleteJSON<T>(path: string): Promise<T> {
const res = await authFetch(path, { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Fehler: ${res.status}`);
}
return res.json();
}
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export async function login(username: string, password: string): Promise<{ token: string; username: string }> {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || "Login fehlgeschlagen");
}
const data = await res.json();
setAuth(data.token, data.username);
return data;
}
export async function verifyToken(): Promise<boolean> {
try {
await fetchJSON("/auth/me");
return true;
} catch {
return false;
}
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await putJSON("/auth/password", {
current_password: currentPassword,
new_password: newPassword,
});
}
// ---------------------------------------------------------------------------
// Settings API
// ---------------------------------------------------------------------------
export interface AppSettings {
[key: string]: { value: string; value_type: string; category: string; label: string; description: string };
}
export async function getSettings(): Promise<AppSettings> {
return fetchJSON("/admin/settings");
}
export async function updateSettings(settings: Record<string, unknown>): Promise<void> {
await putJSON("/admin/settings", { settings });
}
// ---------------------------------------------------------------------------
// Integrations API
// ---------------------------------------------------------------------------
export interface Integration {
id: number;
type: string;
name: string;
config: Record<string, unknown>;
enabled: boolean;
display_order: number;
}
export async function getIntegrations(): Promise<Integration[]> {
return fetchJSON("/admin/integrations");
}
export async function getIntegration(type: string): Promise<Integration> {
return fetchJSON(`/admin/integrations/${type}`);
}
export async function updateIntegration(
type: string,
data: { name?: string; config?: Record<string, unknown>; enabled?: boolean; display_order?: number }
): Promise<Integration> {
return putJSON(`/admin/integrations/${type}`, data);
}
export async function testIntegration(type: string): Promise<{ success: boolean; message: string }> {
return postJSON(`/admin/integrations/${type}/test`);
}
// ---------------------------------------------------------------------------
// MQTT Subscriptions API
// ---------------------------------------------------------------------------
export interface MqttSubscription {
id: number;
topic_pattern: string;
display_name: string;
category: string;
unit: string;
widget_type: string;
enabled: boolean;
display_order: number;
}
export async function getMqttSubscriptions(): Promise<MqttSubscription[]> {
return fetchJSON("/admin/mqtt/subscriptions");
}
export async function createMqttSubscription(data: Omit<MqttSubscription, "id">): Promise<MqttSubscription> {
return postJSON("/admin/mqtt/subscriptions", data);
}
export async function updateMqttSubscription(
id: number,
data: Partial<Omit<MqttSubscription, "id">>
): Promise<MqttSubscription> {
return putJSON(`/admin/mqtt/subscriptions/${id}`, data);
}
export async function deleteMqttSubscription(id: number): Promise<void> {
await deleteJSON(`/admin/mqtt/subscriptions/${id}`);
}

View file

@ -0,0 +1,63 @@
interface Props {
label: string;
description?: string;
children: React.ReactNode;
}
export default function FormField({ label, description, children }: Props) {
return (
<div>
<label className="block text-xs font-medium text-slate-300 mb-1.5">{label}</label>
{description && <p className="text-[11px] text-slate-500 mb-2">{description}</p>}
{children}
</div>
);
}
export function TextInput({
value,
onChange,
placeholder,
type = "text",
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
}) {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
);
}
export function NumberInput({
value,
onChange,
min,
max,
step,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
}) {
return (
<input
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
min={min}
max={max}
step={step}
className="w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors"
/>
);
}

View file

@ -0,0 +1,98 @@
import { useState, ReactNode, FormEvent } from "react";
import { Loader2, Save, CheckCircle2 } from "lucide-react";
import { updateIntegration, type Integration } from "../api";
import TestButton from "./TestButton";
interface Props {
integration: Integration;
onSaved: (updated: Integration) => void;
children: (config: Record<string, unknown>, setConfig: (key: string, value: unknown) => void) => ReactNode;
}
export default function IntegrationForm({ integration, onSaved, children }: Props) {
const [config, setConfigState] = useState<Record<string, unknown>>(integration.config || {});
const [enabled, setEnabled] = useState(integration.enabled);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const setConfig = (key: string, value: unknown) => {
setConfigState((prev) => ({ ...prev, [key]: value }));
setSaved(false);
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
setSaved(false);
try {
const updated = await updateIntegration(integration.type, { config, enabled });
onSaved(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err: any) {
setError(err.message || "Speichern fehlgeschlagen");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
<div>
<p className="text-sm font-medium text-white">{integration.name}</p>
<p className="text-xs text-slate-500 mt-0.5">Integration {enabled ? "aktiv" : "deaktiviert"}</p>
</div>
<button
type="button"
onClick={() => { setEnabled(!enabled); setSaved(false); }}
className={`relative w-11 h-6 rounded-full transition-colors ${
enabled ? "bg-cyan-500" : "bg-slate-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div>
{/* Config fields (rendered by parent) */}
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
{children(config, setConfig)}
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
</button>
<TestButton type={integration.type} disabled={!enabled} />
</div>
</form>
);
}

View file

@ -0,0 +1,21 @@
import { type LucideIcon } from "lucide-react";
interface Props {
icon: LucideIcon;
title: string;
description: string;
}
export default function PageHeader({ icon: Icon, title, description }: Props) {
return (
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center justify-center w-9 h-9 rounded-xl bg-gradient-to-br from-cyan-500/15 to-blue-500/15 border border-white/[0.06]">
<Icon className="w-4 h-4 text-cyan-400" />
</div>
<h1 className="text-lg font-bold tracking-tight text-white">{title}</h1>
</div>
<p className="text-sm text-slate-500 ml-12">{description}</p>
</div>
);
}

View file

@ -0,0 +1,59 @@
import { useState } from "react";
import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react";
import { testIntegration } from "../api";
interface Props {
type: string;
disabled?: boolean;
}
export default function TestButton({ type, disabled }: Props) {
const [status, setStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [message, setMessage] = useState("");
const handleTest = async () => {
setStatus("testing");
setMessage("");
try {
const result = await testIntegration(type);
setStatus(result.success ? "success" : "error");
setMessage(result.message);
} catch (err: any) {
setStatus("error");
setMessage(err.message || "Test fehlgeschlagen");
}
};
return (
<div className="space-y-2">
<button
onClick={handleTest}
disabled={disabled || status === "testing"}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-medium transition-all ${
status === "success"
? "bg-emerald-500/15 border border-emerald-500/30 text-emerald-400"
: status === "error"
? "bg-red-500/15 border border-red-500/30 text-red-400"
: "bg-white/5 border border-white/[0.08] text-slate-300 hover:bg-white/10 hover:text-white"
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{status === "testing" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : status === "success" ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : status === "error" ? (
<XCircle className="w-3.5 h-3.5" />
) : (
<Zap className="w-3.5 h-3.5" />
)}
{status === "testing" ? "Teste..." : "Verbindung testen"}
</button>
{message && (
<p className={`text-xs ${status === "success" ? "text-emerald-400/80" : "text-red-400/80"}`}>
{message}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,114 @@
import { useState, FormEvent } from "react";
import { Lock, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import { changePassword } from "../api";
export default function ChangePassword() {
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const canSubmit = currentPw.length > 0 && newPw.length >= 6 && newPw === confirmPw;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setSaving(true);
setError("");
setSaved(false);
try {
await changePassword(currentPw, newPw);
setSaved(true);
setCurrentPw("");
setNewPw("");
setConfirmPw("");
setTimeout(() => setSaved(false), 5000);
} catch (err: any) {
setError(err.message || "Passwort ändern fehlgeschlagen");
} finally {
setSaving(false);
}
};
return (
<div>
<PageHeader
icon={Lock}
title="Passwort ändern"
description="Admin-Passwort für den Login aktualisieren"
/>
<div className="glass-card p-6 max-w-md">
<form onSubmit={handleSubmit} className="space-y-5">
{saved && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0" />
<p className="text-xs text-emerald-300">Passwort erfolgreich geändert!</p>
</div>
)}
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
<p className="text-xs text-red-300">{error}</p>
</div>
)}
<FormField label="Aktuelles Passwort">
<TextInput
value={currentPw}
onChange={setCurrentPw}
type="password"
placeholder="••••••••"
/>
</FormField>
<FormField label="Neues Passwort" description="Mindestens 6 Zeichen">
<TextInput
value={newPw}
onChange={setNewPw}
type="password"
placeholder="••••••••"
/>
</FormField>
<FormField label="Neues Passwort bestätigen">
<TextInput
value={confirmPw}
onChange={setConfirmPw}
type="password"
placeholder="••••••••"
/>
</FormField>
{newPw && confirmPw && newPw !== confirmPw && (
<p className="text-xs text-amber-400">Passwörter stimmen nicht überein</p>
)}
{newPw && newPw.length > 0 && newPw.length < 6 && (
<p className="text-xs text-amber-400">Mindestens 6 Zeichen erforderlich</p>
)}
<button
type="submit"
disabled={!canSubmit || saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Lock className="w-4 h-4" />
)}
{saving ? "Wird geändert..." : "Passwort ändern"}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useEffect, useState } from "react";
import { Settings, Loader2, Save, CheckCircle2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { NumberInput } from "../components/FormField";
import { getSettings, updateSettings, type AppSettings } from "../api";
export default function GeneralSettings() {
const [settings, setSettings] = useState<AppSettings | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
// Local form values
const [wsInterval, setWsInterval] = useState(15);
const [weatherTtl, setWeatherTtl] = useState(600);
const [newsTtl, setNewsTtl] = useState(300);
const [serversTtl, setServersTtl] = useState(120);
const [haTtl, setHaTtl] = useState(60);
const [tasksTtl, setTasksTtl] = useState(180);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
const data = await getSettings();
setSettings(data);
// Populate form from loaded settings
if (data.ws_interval) setWsInterval(Number(data.ws_interval.value));
if (data.weather_cache_ttl) setWeatherTtl(Number(data.weather_cache_ttl.value));
if (data.news_cache_ttl) setNewsTtl(Number(data.news_cache_ttl.value));
if (data.unraid_cache_ttl) setServersTtl(Number(data.unraid_cache_ttl.value));
if (data.ha_cache_ttl) setHaTtl(Number(data.ha_cache_ttl.value));
if (data.tasks_cache_ttl) setTasksTtl(Number(data.tasks_cache_ttl.value));
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setError("");
setSaved(false);
try {
await updateSettings({
ws_interval: wsInterval,
weather_cache_ttl: weatherTtl,
news_cache_ttl: newsTtl,
unraid_cache_ttl: serversTtl,
ha_cache_ttl: haTtl,
tasks_cache_ttl: tasksTtl,
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
return (
<div>
<PageHeader
icon={Settings}
title="Allgemeine Einstellungen"
description="Cache-Zeiten und Dashboard-Verhalten konfigurieren"
/>
<div className="glass-card p-6 space-y-6">
<h3 className="text-sm font-semibold text-white">WebSocket Intervall</h3>
<FormField label="Update-Intervall (Sekunden)" description="Wie oft das Dashboard automatisch neue Daten empfängt">
<NumberInput value={wsInterval} onChange={setWsInterval} min={5} max={120} step={5} />
</FormField>
<div className="border-t border-white/[0.04] my-2" />
<h3 className="text-sm font-semibold text-white">Cache TTLs (Sekunden)</h3>
<p className="text-xs text-slate-500">Wie lange gecachte Daten gültig sind bevor sie neu geladen werden</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Wetter">
<NumberInput value={weatherTtl} onChange={setWeatherTtl} min={60} max={3600} />
</FormField>
<FormField label="News">
<NumberInput value={newsTtl} onChange={setNewsTtl} min={60} max={3600} />
</FormField>
<FormField label="Unraid Server">
<NumberInput value={serversTtl} onChange={setServersTtl} min={30} max={3600} />
</FormField>
<FormField label="Home Assistant">
<NumberInput value={haTtl} onChange={setHaTtl} min={10} max={3600} />
</FormField>
<FormField label="Tasks (Vikunja)">
<NumberInput value={tasksTtl} onChange={setTasksTtl} min={30} max={3600} />
</FormField>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { Home, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function HASettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("ha");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={Home} title="Home Assistant" description="Home Assistant konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Home}
title="Home Assistant"
description="Verbindung zu deiner Home Assistant Instanz konfigurieren"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Home Assistant URL" description="Basis-URL deiner HA-Instanz (inkl. Port)">
<TextInput
value={(config.url as string) || ""}
onChange={(v) => setConfig("url", v)}
placeholder="http://10.10.10.50:8123"
/>
</FormField>
<FormField label="Long-Lived Access Token" description="Erstelle diesen Token in HA unter Profil → Langlebige Zugangstoken">
<TextInput
value={(config.token as string) || ""}
onChange={(v) => setConfig("token", v)}
type="password"
placeholder="eyJhbGciOi..."
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

@ -0,0 +1,343 @@
import { useEffect, useState } from "react";
import {
Radio, Loader2, Plus, Trash2, Save, CheckCircle2, Edit2, X, Check,
} from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput, NumberInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import {
getIntegration,
getMqttSubscriptions,
createMqttSubscription,
updateMqttSubscription,
deleteMqttSubscription,
type Integration,
type MqttSubscription,
} from "../api";
export default function MqttSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [subscriptions, setSubscriptions] = useState<MqttSubscription[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [integ, subs] = await Promise.all([
getIntegration("mqtt"),
getMqttSubscriptions(),
]);
setIntegration(integ);
setSubscriptions(subs);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error && !integration) {
return (
<div>
<PageHeader icon={Radio} title="MQTT" description="MQTT Broker konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
icon={Radio}
title="MQTT"
description="MQTT Broker und Topic-Subscriptions verwalten"
/>
{/* Broker Config */}
{integration && (
<div className="glass-card p-6">
<h3 className="text-sm font-semibold text-white mb-4">Broker-Verbindung</h3>
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Broker Host">
<TextInput
value={(config.host as string) || ""}
onChange={(v) => setConfig("host", v)}
placeholder="z.B. 10.10.10.50"
/>
</FormField>
<FormField label="Port">
<NumberInput
value={Number(config.port) || 1883}
onChange={(v) => setConfig("port", v)}
min={1}
max={65535}
/>
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Benutzername" description="Optional">
<TextInput
value={(config.username as string) || ""}
onChange={(v) => setConfig("username", v)}
/>
</FormField>
<FormField label="Passwort" description="Optional">
<TextInput
value={(config.password as string) || ""}
onChange={(v) => setConfig("password", v)}
type="password"
/>
</FormField>
</div>
<FormField label="Client ID" description="Eindeutige ID für diese MQTT-Verbindung">
<TextInput
value={(config.client_id as string) || ""}
onChange={(v) => setConfig("client_id", v)}
placeholder="daily-briefing"
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
)}
{/* Subscriptions */}
<div className="glass-card p-6">
<SubscriptionManager
subscriptions={subscriptions}
onUpdate={setSubscriptions}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Subscription Manager sub-component
// ---------------------------------------------------------------------------
function SubscriptionManager({
subscriptions,
onUpdate,
}: {
subscriptions: MqttSubscription[];
onUpdate: (subs: MqttSubscription[]) => void;
}) {
const [adding, setAdding] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [error, setError] = useState("");
// New subscription form
const [newTopic, setNewTopic] = useState("");
const [newName, setNewName] = useState("");
const [newCategory, setNewCategory] = useState("other");
const [newUnit, setNewUnit] = useState("");
const [newWidget, setNewWidget] = useState("value");
const handleAdd = async () => {
if (!newTopic.trim()) return;
setError("");
try {
const created = await createMqttSubscription({
topic_pattern: newTopic.trim(),
display_name: newName.trim() || newTopic.trim(),
category: newCategory,
unit: newUnit,
widget_type: newWidget,
enabled: true,
display_order: subscriptions.length,
});
onUpdate([...subscriptions, created]);
resetNewForm();
} catch (err: any) {
setError(err.message);
}
};
const handleDelete = async (id: number) => {
setError("");
try {
await deleteMqttSubscription(id);
onUpdate(subscriptions.filter((s) => s.id !== id));
} catch (err: any) {
setError(err.message);
}
};
const handleToggle = async (sub: MqttSubscription) => {
setError("");
try {
const updated = await updateMqttSubscription(sub.id, { enabled: !sub.enabled });
onUpdate(subscriptions.map((s) => (s.id === sub.id ? updated : s)));
} catch (err: any) {
setError(err.message);
}
};
const resetNewForm = () => {
setAdding(false);
setNewTopic("");
setNewName("");
setNewCategory("other");
setNewUnit("");
setNewWidget("value");
};
const CATEGORIES = ["system", "sensor", "docker", "network", "other"];
const WIDGETS = ["value", "gauge", "switch", "badge"];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Topic-Subscriptions</h3>
<span className="text-xs text-slate-500">{subscriptions.length} Topics</span>
</div>
{/* Existing subscriptions */}
<div className="space-y-2">
{subscriptions.map((sub) => (
<div
key={sub.id}
className={`flex items-center gap-3 p-3 rounded-xl border transition-colors ${
sub.enabled
? "bg-white/[0.02] border-white/[0.06]"
: "bg-white/[0.01] border-white/[0.03] opacity-50"
}`}
>
<button
onClick={() => handleToggle(sub)}
className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
sub.enabled ? "bg-cyan-500" : "bg-slate-700"
}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
sub.enabled ? "translate-x-4" : "translate-x-0"
}`} />
</button>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-white truncate">{sub.display_name || sub.topic_pattern}</p>
<p className="text-[10px] text-slate-500 font-mono truncate">{sub.topic_pattern}</p>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-white/5 text-slate-400 flex-shrink-0">
{sub.category}
</span>
{sub.unit && (
<span className="text-[10px] text-slate-500 flex-shrink-0">{sub.unit}</span>
)}
<button
onClick={() => handleDelete(sub.id)}
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-500/10 transition-colors flex-shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
{subscriptions.length === 0 && !adding && (
<p className="text-xs text-slate-600 text-center py-6">
Keine Subscriptions konfiguriert. Füge einen MQTT-Topic hinzu um Daten zu empfangen.
</p>
)}
</div>
{/* Add new */}
{adding ? (
<div className="p-4 rounded-xl bg-white/[0.03] border border-cyan-500/20 space-y-3">
<p className="text-xs font-semibold text-cyan-400">Neuer Topic</p>
<FormField label="Topic Pattern" description="z.B. unraid/server1/cpu/usage oder sensors/#">
<TextInput value={newTopic} onChange={setNewTopic} placeholder="unraid/+/cpu/usage" />
</FormField>
<FormField label="Anzeigename">
<TextInput value={newName} onChange={setNewName} placeholder="CPU Auslastung" />
</FormField>
<div className="grid grid-cols-3 gap-3">
<FormField label="Kategorie">
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white focus:outline-none focus:border-cyan-500/50 transition-colors"
>
{CATEGORIES.map((c) => (
<option key={c} value={c} className="bg-slate-900">{c}</option>
))}
</select>
</FormField>
<FormField label="Einheit">
<TextInput value={newUnit} onChange={setNewUnit} placeholder="%, °C, MB" />
</FormField>
<FormField label="Widget">
<select
value={newWidget}
onChange={(e) => setNewWidget(e.target.value)}
className="w-full px-3 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white focus:outline-none focus:border-cyan-500/50 transition-colors"
>
{WIDGETS.map((w) => (
<option key={w} value={w} className="bg-slate-900">{w}</option>
))}
</select>
</FormField>
</div>
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleAdd}
disabled={!newTopic.trim()}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-cyan-500/20 border border-cyan-500/30 text-xs font-medium text-cyan-400 hover:bg-cyan-500/30 transition-colors disabled:opacity-40"
>
<Check className="w-3.5 h-3.5" />
Hinzufügen
</button>
<button
onClick={resetNewForm}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white/5 border border-white/[0.08] text-xs text-slate-400 hover:text-white transition-colors"
>
<X className="w-3.5 h-3.5" />
Abbrechen
</button>
</div>
</div>
) : (
<button
onClick={() => setAdding(true)}
className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all"
>
<Plus className="w-4 h-4" />
Topic-Subscription hinzufügen
</button>
)}
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { Newspaper, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { NumberInput, TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function NewsSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("news");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={Newspaper} title="News" description="News-Datenbank konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Newspaper}
title="News"
description="News-Datenquelle und Anzeige konfigurieren"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Datenbank Host" description="PostgreSQL Host für die News-Datenbank">
<TextInput
value={(config.db_host as string) || ""}
onChange={(v) => setConfig("db_host", v)}
placeholder="z.B. 10.10.10.100"
/>
</FormField>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Datenbank Port">
<NumberInput
value={Number(config.db_port) || 5432}
onChange={(v) => setConfig("db_port", v)}
min={1}
max={65535}
/>
</FormField>
<FormField label="Datenbank Name">
<TextInput
value={(config.db_name as string) || ""}
onChange={(v) => setConfig("db_name", v)}
placeholder="market_news"
/>
</FormField>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField label="Datenbank User">
<TextInput
value={(config.db_user as string) || ""}
onChange={(v) => setConfig("db_user", v)}
placeholder="postgres"
/>
</FormField>
<FormField label="Datenbank Passwort">
<TextInput
value={(config.db_password as string) || ""}
onChange={(v) => setConfig("db_password", v)}
type="password"
/>
</FormField>
</div>
<FormField label="Max. Alter (Stunden)" description="Nur Artikel anzeigen die jünger sind als dieser Wert">
<NumberInput
value={Number(config.max_age_hours) || 72}
onChange={(v) => setConfig("max_age_hours", v)}
min={1}
max={720}
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

@ -0,0 +1,219 @@
import { useEffect, useState } from "react";
import { Server, Loader2, Plus, Trash2, Save, CheckCircle2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput, NumberInput } from "../components/FormField";
import TestButton from "../components/TestButton";
import { getIntegration, updateIntegration, type Integration } from "../api";
interface ServerEntry {
name: string;
host: string;
api_key: string;
port: number;
}
export default function UnraidSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [enabled, setEnabled] = useState(false);
const [servers, setServers] = useState<ServerEntry[]>([]);
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("unraid");
setIntegration(data);
setEnabled(data.enabled);
const cfg = data.config || {};
const rawServers = cfg.servers as ServerEntry[] | undefined;
if (Array.isArray(rawServers) && rawServers.length > 0) {
setServers(rawServers);
} else {
// Legacy single-server or empty
setServers([]);
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const addServer = () => {
setServers((prev) => [...prev, { name: "", host: "", api_key: "", port: 80 }]);
setSaved(false);
};
const removeServer = (index: number) => {
setServers((prev) => prev.filter((_, i) => i !== index));
setSaved(false);
};
const updateServer = (index: number, field: keyof ServerEntry, value: string | number) => {
setServers((prev) => prev.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
setSaved(false);
};
const handleSave = async () => {
if (!integration) return;
setSaving(true);
setError("");
setSaved(false);
try {
const updated = await updateIntegration(integration.type, {
enabled,
config: { servers },
});
setIntegration(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error && !integration) {
return (
<div>
<PageHeader icon={Server} title="Unraid Server" description="Unraid-Server verwalten" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Server}
title="Unraid Server"
description="Unraid-Server hinzufügen, bearbeiten oder entfernen"
/>
<div className="glass-card p-6 space-y-6">
{/* Enable toggle */}
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
<div>
<p className="text-sm font-medium text-white">Unraid Integration</p>
<p className="text-xs text-slate-500 mt-0.5">{enabled ? "Aktiv" : "Deaktiviert"}</p>
</div>
<button
type="button"
onClick={() => { setEnabled(!enabled); setSaved(false); }}
className={`relative w-11 h-6 rounded-full transition-colors ${
enabled ? "bg-cyan-500" : "bg-slate-700"
}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-5" : "translate-x-0"
}`} />
</button>
</div>
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
{/* Server list */}
{servers.map((srv, idx) => (
<div key={idx} className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-300">Server #{idx + 1}</span>
<button
onClick={() => removeServer(idx)}
className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Server entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<FormField label="Name">
<TextInput
value={srv.name}
onChange={(v) => updateServer(idx, "name", v)}
placeholder="z.B. Main Server"
/>
</FormField>
<FormField label="Host">
<TextInput
value={srv.host}
onChange={(v) => updateServer(idx, "host", v)}
placeholder="z.B. 10.10.10.100"
/>
</FormField>
<FormField label="API Key">
<TextInput
value={srv.api_key}
onChange={(v) => updateServer(idx, "api_key", v)}
type="password"
/>
</FormField>
<FormField label="Port">
<NumberInput
value={srv.port}
onChange={(v) => updateServer(idx, "port", v)}
min={1}
max={65535}
/>
</FormField>
</div>
</div>
))}
{/* Add server button */}
<button
onClick={addServer}
className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all"
>
<Plus className="w-4 h-4" />
Server hinzufügen
</button>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20">
<p className="text-xs text-red-300">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-500 to-blue-500 text-sm font-semibold text-white hover:from-cyan-400 hover:to-blue-400 transition-all disabled:opacity-40"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : saved ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? "Speichern..." : saved ? "Gespeichert!" : "Speichern"}
</button>
{integration && <TestButton type={integration.type} disabled={!enabled} />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { ListTodo, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function VikunjaSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("vikunja");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={ListTodo} title="Vikunja" description="Vikunja Task-Manager konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={ListTodo}
title="Vikunja"
description="Vikunja-Instanz und Projekte für die Task-Anzeige konfigurieren"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Vikunja URL" description="Basis-URL deiner Vikunja-Instanz">
<TextInput
value={(config.url as string) || ""}
onChange={(v) => setConfig("url", v)}
placeholder="http://10.10.10.50:3456"
/>
</FormField>
<FormField label="API Token" description="Erstelle einen Token in Vikunja unter Einstellungen → API Tokens">
<TextInput
value={(config.token as string) || ""}
onChange={(v) => setConfig("token", v)}
type="password"
placeholder="tk_..."
/>
</FormField>
<FormField
label="Private Projekt-IDs"
description="Komma-getrennte Liste von Projekt-IDs für den privaten Bereich"
>
<TextInput
value={(config.private_projects as string) || ""}
onChange={(v) => setConfig("private_projects", v)}
placeholder="z.B. 1,5,12"
/>
</FormField>
<FormField
label="Sam's Projekt-IDs"
description="Komma-getrennte Liste von Projekt-IDs für den gemeinsamen Bereich"
>
<TextInput
value={(config.sams_projects as string) || ""}
onChange={(v) => setConfig("sams_projects", v)}
placeholder="z.B. 3,8"
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { Cloud, Loader2 } from "lucide-react";
import PageHeader from "../components/PageHeader";
import FormField, { TextInput } from "../components/FormField";
import IntegrationForm from "../components/IntegrationForm";
import { getIntegration, type Integration } from "../api";
export default function WeatherSettings() {
const [integration, setIntegration] = useState<Integration | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
loadIntegration();
}, []);
const loadIntegration = async () => {
try {
const data = await getIntegration("weather");
setIntegration(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-5 h-5 text-slate-400 animate-spin" />
</div>
);
}
if (error || !integration) {
return (
<div>
<PageHeader icon={Cloud} title="Wetter" description="Wetteranbieter konfigurieren" />
<div className="glass-card p-6">
<p className="text-sm text-red-400">{error || "Integration nicht gefunden"}</p>
</div>
</div>
);
}
return (
<div>
<PageHeader
icon={Cloud}
title="Wetter"
description="Standorte für die Wettervorhersage konfigurieren (wttr.in)"
/>
<div className="glass-card p-6">
<IntegrationForm integration={integration} onSaved={setIntegration}>
{(config, setConfig) => (
<>
<FormField label="Primärer Standort" description="Stadt oder Koordinaten für die Hauptanzeige">
<TextInput
value={(config.primary_location as string) || ""}
onChange={(v) => setConfig("primary_location", v)}
placeholder="z.B. Berlin oder 52.52,13.405"
/>
</FormField>
<FormField label="Sekundärer Standort" description="Zweiter Standort für den Vergleich">
<TextInput
value={(config.secondary_location as string) || ""}
onChange={(v) => setConfig("secondary_location", v)}
placeholder="z.B. München oder 48.137,11.576"
/>
</FormField>
</>
)}
</IntegrationForm>
</div>
</div>
);
}