refactor: complete rewrite as React+FastAPI dashboard

Replace monolithic Jinja2 template with modern stack:

Backend (FastAPI):
- Modular router/service architecture
- Async PostgreSQL (asyncpg) for news from n8n pipeline
- Live Unraid server stats (2 servers via API)
- Home Assistant, Vikunja tasks, weather (wttr.in)
- WebSocket broadcast for real-time updates (15s)
- TTL cache per endpoint, all config via ENV vars

Frontend (React + Vite + TypeScript):
- Glassmorphism dark theme with Tailwind CSS
- Responsive grid: mobile/tablet/desktop/ultrawide
- Weather cards, hourly forecast, news with category tabs
- Server stats (CPU ring, RAM bar, Docker list)
- Home Assistant controls, task management
- Live clock, WebSocket connection indicator

Infrastructure:
- Multi-stage Dockerfile (node:22-alpine + python:3.11-slim)
- docker-compose with full ENV configuration
- Kaniko CI/CD pipeline for GitLab registry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 01:48:51 +01:00
parent 4bbc125a67
commit 9f7330e217
48 changed files with 6390 additions and 1461 deletions

View file

@ -0,0 +1,183 @@
import { useState } from "react";
import { CheckSquare, Square, AlertTriangle, Calendar } from "lucide-react";
import type { TasksResponse, Task } from "../api";
interface TasksCardProps {
data: TasksResponse;
}
type TabKey = "private" | "sams";
const TABS: { key: TabKey; label: string }[] = [
{ key: "private", label: "Privat" },
{ key: "sams", label: "Sam's" },
];
/** Colour for task priority (1 = highest). */
function priorityIndicator(priority: number): { color: string; label: string } {
if (priority <= 1) return { color: "bg-red-500", label: "Hoch" };
if (priority <= 2) return { color: "bg-amber-500", label: "Mittel" };
if (priority <= 3) return { color: "bg-blue-500", label: "Normal" };
return { color: "bg-slate-500", label: "Niedrig" };
}
/** Format a due date string to a short German date. */
function formatDueDate(iso: string | null): string | null {
if (!iso) return null;
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return null;
const now = new Date();
const diffDays = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return "Ueberfaellig";
if (diffDays === 0) return "Heute";
if (diffDays === 1) return "Morgen";
return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
} catch {
return null;
}
}
export default function TasksCard({ data }: TasksCardProps) {
const [activeTab, setActiveTab] = useState<TabKey>("private");
if (!data) return null;
if (data.error) {
return (
<div className="glass-card p-5 animate-fade-in">
<div className="flex items-center gap-3 text-red-400">
<AlertTriangle className="w-5 h-5" />
<div>
<p className="text-sm font-medium">Aufgaben nicht verfuegbar</p>
<p className="text-xs text-slate-500 mt-0.5">Verbindung fehlgeschlagen</p>
</div>
</div>
</div>
);
}
const group = activeTab === "private" ? data.private : data.sams;
const tasks = group?.open ?? [];
return (
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-blue-500/[0.04] to-transparent">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20">
<CheckSquare className="w-4 h-4 text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-white">Aufgaben</h3>
</div>
</div>
{/* Tab buttons */}
<div className="flex gap-2 mb-4">
{TABS.map((tab) => {
const tabGroup = tab.key === "private" ? data.private : data.sams;
const openCount = tabGroup?.open_count ?? 0;
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`
flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-medium transition-all duration-200
${
isActive
? "bg-blue-500/15 text-blue-300 border border-blue-500/20"
: "bg-white/[0.03] text-slate-400 border border-white/[0.04] hover:bg-white/[0.06] hover:text-slate-300"
}
`}
>
{tab.label}
{openCount > 0 && (
<span
className={`
inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full text-[10px] font-bold
${isActive ? "bg-blue-500/25 text-blue-200" : "bg-white/[0.06] text-slate-500"}
`}
>
{openCount}
</span>
)}
</button>
);
})}
</div>
{/* Task list */}
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-slate-600">
<CheckSquare className="w-8 h-8 mb-2 opacity-30" />
<p className="text-xs">Alles erledigt!</p>
</div>
) : (
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
)}
</div>
);
}
function TaskItem({ task }: { task: Task }) {
const p = priorityIndicator(task.priority);
const due = formatDueDate(task.due_date);
const isOverdue = due === "Ueberfaellig";
return (
<div className="flex items-start gap-3 px-3 py-2.5 rounded-xl bg-white/[0.02] border border-white/[0.04] hover:bg-white/[0.04] transition-colors group">
{/* Visual checkbox */}
<div className="mt-0.5 flex-shrink-0">
{task.done ? (
<CheckSquare className="w-4 h-4 text-blue-400" />
) : (
<Square className="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-snug ${
task.done ? "text-slate-600 line-through" : "text-slate-200"
}`}
>
{task.title}
</p>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Project badge */}
{task.project_name && (
<span className="badge bg-indigo-500/15 text-indigo-300">
{task.project_name}
</span>
)}
{/* Due date */}
{due && (
<span
className={`flex items-center gap-1 text-[10px] font-medium ${
isOverdue ? "text-red-400" : "text-slate-500"
}`}
>
<Calendar className="w-2.5 h-2.5" />
{due}
</span>
)}
</div>
</div>
{/* Priority dot */}
<div className="flex-shrink-0 mt-1.5" title={p.label}>
<div className={`w-2 h-2 rounded-full ${p.color}`} />
</div>
</div>
);
}