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,144 @@
import { useState, useMemo } from "react";
import { ExternalLink } from "lucide-react";
import type { NewsResponse, NewsArticle } from "../api";
interface NewsGridProps {
data: NewsResponse;
}
const CATEGORIES = [
{ key: "all", label: "Alle" },
{ key: "tech", label: "Tech" },
{ key: "wirtschaft", label: "Wirtschaft" },
{ key: "politik", label: "Politik" },
{ key: "allgemein", label: "Allgemein" },
] as const;
/** Map source names to badge colours. */
const SOURCE_COLORS: Record<string, string> = {
"heise": "bg-orange-500/20 text-orange-300",
"golem": "bg-blue-500/20 text-blue-300",
"spiegel": "bg-red-500/20 text-red-300",
"tagesschau": "bg-sky-500/20 text-sky-300",
"zeit": "bg-slate-500/20 text-slate-300",
"faz": "bg-emerald-500/20 text-emerald-300",
"welt": "bg-indigo-500/20 text-indigo-300",
"t3n": "bg-purple-500/20 text-purple-300",
"default": "bg-amber-500/15 text-amber-300",
};
function sourceColor(source: string): string {
const key = source.toLowerCase();
for (const [prefix, cls] of Object.entries(SOURCE_COLORS)) {
if (key.includes(prefix)) return cls;
}
return SOURCE_COLORS.default;
}
/** Return a German relative time string like "vor 2 Stunden". */
function relativeTime(isoDate: string): string {
try {
const date = new Date(isoDate);
if (isNaN(date.getTime())) return "";
const diff = Date.now() - date.getTime();
const seconds = Math.floor(diff / 1_000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "gerade eben";
if (minutes < 60) return `vor ${minutes} Min.`;
if (hours < 24) return hours === 1 ? "vor 1 Stunde" : `vor ${hours} Stunden`;
if (days === 1) return "gestern";
if (days < 7) return `vor ${days} Tagen`;
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "short" });
} catch {
return "";
}
}
export default function NewsGrid({ data }: NewsGridProps) {
const [activeCategory, setActiveCategory] = useState<string>("all");
const filteredArticles = useMemo(() => {
if (!data?.articles) return [];
if (activeCategory === "all") return data.articles;
return data.articles.filter(
(a) => a.category?.toLowerCase() === activeCategory
);
}, [data, activeCategory]);
if (!data?.articles) return null;
return (
<div className="animate-fade-in">
{/* Header + category tabs */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-400">
Nachrichten
<span className="ml-2 text-xs font-normal text-slate-600">
{filteredArticles.length}
</span>
</h2>
<div className="flex gap-1 flex-wrap">
{CATEGORIES.map((cat) => (
<button
key={cat.key}
onClick={() => setActiveCategory(cat.key)}
className={`category-tab ${activeCategory === cat.key ? "active" : ""}`}
>
{cat.label}
</button>
))}
</div>
</div>
{/* Articles grid */}
{filteredArticles.length === 0 ? (
<div className="glass-card p-8 text-center text-slate-500 text-sm">
Keine Artikel in dieser Kategorie.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredArticles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
</div>
);
}
function ArticleCard({ article }: { article: NewsArticle }) {
return (
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="glass-card-hover group block p-4 cursor-pointer"
>
<div className="flex items-center justify-between mb-2.5">
<span className={`badge ${sourceColor(article.source)}`}>
{article.source}
</span>
<ExternalLink className="w-3 h-3 text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<h3 className="text-sm font-medium text-slate-200 leading-snug line-clamp-2 group-hover:text-white transition-colors">
{article.title}
</h3>
<div className="flex items-center gap-2 mt-3">
{article.category && (
<span className="text-[10px] text-slate-600 uppercase tracking-wider">
{article.category}
</span>
)}
<span className="text-[10px] text-slate-600 ml-auto">
{relativeTime(article.published_at)}
</span>
</div>
</a>
);
}