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:
parent
4bbc125a67
commit
9f7330e217
48 changed files with 6390 additions and 1461 deletions
15
web/index.html
Normal file
15
web/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Daily Briefing</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2785
web/package-lock.json
generated
Normal file
2785
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
web/package.json
Normal file
26
web/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "daily-briefing-web",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
171
web/src/App.tsx
Normal file
171
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useDashboard } from "./hooks/useDashboard";
|
||||
import Clock from "./components/Clock";
|
||||
import WeatherCard from "./components/WeatherCard";
|
||||
import HourlyForecast from "./components/HourlyForecast";
|
||||
import NewsGrid from "./components/NewsGrid";
|
||||
import ServerCard from "./components/ServerCard";
|
||||
import HomeAssistant from "./components/HomeAssistant";
|
||||
import TasksCard from "./components/TasksCard";
|
||||
import { RefreshCw, Wifi, WifiOff, AlertTriangle } from "lucide-react";
|
||||
|
||||
export default function App() {
|
||||
const { data, loading, error, connected, refresh } = useDashboard();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white">
|
||||
{/* ---- Error banner ---- */}
|
||||
{error && (
|
||||
<div className="fixed top-0 inset-x-0 z-50 flex items-center justify-center gap-2 bg-red-500/15 border-b border-red-500/20 backdrop-blur-sm px-4 py-2 animate-slide-up">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||
<p className="text-xs text-red-300">{error}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="ml-3 text-xs text-red-300 hover:text-white underline underline-offset-2 transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Header bar ---- */}
|
||||
<header className="sticky top-0 z-40 border-b border-white/[0.04] bg-slate-950/80 backdrop-blur-lg">
|
||||
<div className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
{/* Left: title + live indicator */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-base sm:text-lg font-bold tracking-tight text-white">
|
||||
Daily Briefing
|
||||
</h1>
|
||||
<LiveIndicator connected={connected} />
|
||||
</div>
|
||||
|
||||
{/* Right: clock + refresh */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 border border-white/[0.06] hover:bg-white/10 transition-colors disabled:opacity-40"
|
||||
title="Daten aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-slate-400 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<Clock />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ---- Main content ---- */}
|
||||
<main className="max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
{loading && !data ? (
|
||||
<LoadingSkeleton />
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Row 1: Weather cards + Hourly forecast */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<WeatherCard data={data.weather.primary} accent="cyan" />
|
||||
<WeatherCard data={data.weather.secondary} accent="amber" />
|
||||
<div className="md:col-span-2">
|
||||
<HourlyForecast slots={data.weather.hourly} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Row 2: Servers + Home Assistant + Tasks */}
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{data.servers.servers.map((srv) => (
|
||||
<ServerCard key={srv.name} server={srv} />
|
||||
))}
|
||||
<HomeAssistant data={data.ha} />
|
||||
<TasksCard data={data.tasks} />
|
||||
</section>
|
||||
|
||||
{/* Row 3: News (full width) */}
|
||||
<section>
|
||||
<NewsGrid data={data.news} />
|
||||
</section>
|
||||
|
||||
{/* Footer timestamp */}
|
||||
<footer className="text-center pb-4">
|
||||
<p className="text-[10px] text-slate-700">
|
||||
Letzte Aktualisierung:{" "}
|
||||
{new Date(data.timestamp).toLocaleTimeString("de-DE", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</footer>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small pulsing dot indicating live WebSocket connection. */
|
||||
function LiveIndicator({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white/5 border border-white/[0.06]">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-emerald-400" : "bg-slate-600"}`}
|
||||
/>
|
||||
{connected && (
|
||||
<div className="absolute inset-0 w-1.5 h-1.5 rounded-full bg-emerald-400 animate-ping opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium ${connected ? "text-emerald-400" : "text-slate-600"}`}>
|
||||
{connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
{connected ? (
|
||||
<Wifi className="w-3 h-3 text-emerald-400/50" />
|
||||
) : (
|
||||
<WifiOff className="w-3 h-3 text-slate-600" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton loading state displayed on first load. */
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
{/* Row 1: Weather placeholders */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<SkeletonCard className="h-52" />
|
||||
<SkeletonCard className="h-52" />
|
||||
<SkeletonCard className="h-24 md:col-span-2" />
|
||||
</div>
|
||||
|
||||
{/* Row 2: Info cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<SkeletonCard className="h-72" />
|
||||
<SkeletonCard className="h-72" />
|
||||
<SkeletonCard className="h-72" />
|
||||
<SkeletonCard className="h-72" />
|
||||
</div>
|
||||
|
||||
{/* Row 3: News */}
|
||||
<div>
|
||||
<div className="h-6 w-32 rounded bg-white/5 mb-4" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} className="h-28" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonCard({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`glass-card ${className}`}
|
||||
>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="h-3 w-1/3 rounded bg-white/5" />
|
||||
<div className="h-2 w-2/3 rounded bg-white/[0.03]" />
|
||||
<div className="h-2 w-1/2 rounded bg-white/[0.03]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
web/src/api.ts
Normal file
120
web/src/api.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/** API client for the Daily Briefing backend. */
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function fetchJSON<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`);
|
||||
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface WeatherData {
|
||||
location: string;
|
||||
temp: number;
|
||||
feels_like: number;
|
||||
humidity: number;
|
||||
wind_kmh: number;
|
||||
description: string;
|
||||
icon: string;
|
||||
error?: boolean;
|
||||
forecast: { date: string; max_temp: number; min_temp: number; icon: string; description: string }[];
|
||||
}
|
||||
|
||||
export interface HourlySlot {
|
||||
time: string;
|
||||
temp: number;
|
||||
icon: string;
|
||||
precip_chance: number;
|
||||
}
|
||||
|
||||
export interface WeatherResponse {
|
||||
primary: WeatherData;
|
||||
secondary: WeatherData;
|
||||
hourly: HourlySlot[];
|
||||
}
|
||||
|
||||
export interface NewsArticle {
|
||||
id: number;
|
||||
source: string;
|
||||
title: string;
|
||||
url: string;
|
||||
category: string | null;
|
||||
published_at: string;
|
||||
}
|
||||
|
||||
export interface NewsResponse {
|
||||
articles: NewsArticle[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
name: string;
|
||||
host: string;
|
||||
online: boolean;
|
||||
uptime: string;
|
||||
cpu: { usage_pct: number; cores: number; temp_c: number | null };
|
||||
ram: { used_gb: number; total_gb: number; pct: number };
|
||||
array: { status: string; disks: { name: string; status: string; size: string; used: string }[] };
|
||||
docker: { running: number; containers: { name: string; status: string; image: string }[] };
|
||||
}
|
||||
|
||||
export interface ServersResponse {
|
||||
servers: ServerStats[];
|
||||
}
|
||||
|
||||
export interface HAData {
|
||||
online: boolean;
|
||||
lights: { entity_id: string; name: string; state: string; brightness: number }[];
|
||||
covers: { entity_id: string; name: string; state: string; position: number }[];
|
||||
sensors: { entity_id: string; name: string; state: number; unit: string }[];
|
||||
lights_on: number;
|
||||
lights_total: number;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
done: boolean;
|
||||
priority: number;
|
||||
project_name: string;
|
||||
due_date: string | null;
|
||||
}
|
||||
|
||||
export interface TaskGroup {
|
||||
open: Task[];
|
||||
done: Task[];
|
||||
open_count: number;
|
||||
}
|
||||
|
||||
export interface TasksResponse {
|
||||
private: TaskGroup;
|
||||
sams: TaskGroup;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
weather: WeatherResponse;
|
||||
news: NewsResponse;
|
||||
servers: ServersResponse;
|
||||
ha: HAData;
|
||||
tasks: TasksResponse;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// --- Fetch Functions ---
|
||||
|
||||
export const fetchWeather = () => fetchJSON<WeatherResponse>("/weather");
|
||||
export const fetchNews = (limit = 20, offset = 0, category?: string) => {
|
||||
let q = `/news?limit=${limit}&offset=${offset}`;
|
||||
if (category) q += `&category=${encodeURIComponent(category)}`;
|
||||
return fetchJSON<NewsResponse>(q);
|
||||
};
|
||||
export const fetchServers = () => fetchJSON<ServersResponse>("/servers");
|
||||
export const fetchHA = () => fetchJSON<HAData>("/ha");
|
||||
export const fetchTasks = () => fetchJSON<TasksResponse>("/tasks");
|
||||
export const fetchAll = () => fetchJSON<DashboardData>("/all");
|
||||
46
web/src/components/Clock.tsx
Normal file
46
web/src/components/Clock.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Clock as ClockIcon } from "lucide-react";
|
||||
|
||||
/** Live clock with German-locale date. Updates every second. */
|
||||
export default function Clock() {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const hours = now.getHours().toString().padStart(2, "0");
|
||||
const minutes = now.getMinutes().toString().padStart(2, "0");
|
||||
const seconds = now.getSeconds().toString().padStart(2, "0");
|
||||
|
||||
const dateStr = now.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-10 rounded-xl bg-white/5 border border-white/[0.06]">
|
||||
<ClockIcon className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end sm:items-start">
|
||||
{/* Time display */}
|
||||
<div className="flex items-baseline gap-0.5" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
<span className="text-2xl sm:text-3xl font-bold text-white tracking-tight">
|
||||
{hours}:{minutes}
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-medium text-slate-400">
|
||||
:{seconds}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Date display */}
|
||||
<p className="text-xs text-slate-500 mt-0.5">{dateStr}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
web/src/components/HomeAssistant.tsx
Normal file
191
web/src/components/HomeAssistant.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff, Wifi } from "lucide-react";
|
||||
import type { HAData } from "../api";
|
||||
|
||||
interface HomeAssistantProps {
|
||||
data: HAData;
|
||||
}
|
||||
|
||||
export default function HomeAssistant({ data }: HomeAssistantProps) {
|
||||
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">
|
||||
<WifiOff className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Home Assistant nicht erreichbar</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Verbindung fehlgeschlagen</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-violet-500/[0.04] to-transparent">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
||||
<Home className="w-4 h-4 text-violet-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-white">Home Assistant</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${data.online ? "bg-emerald-400" : "bg-red-400"}`}
|
||||
/>
|
||||
{data.online && (
|
||||
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 animate-ping opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium ${data.online ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{data.online ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Lights Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-violet-400/70" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Lichter
|
||||
</span>
|
||||
</div>
|
||||
<span className="badge bg-violet-500/15 text-violet-300">
|
||||
{data.lights_on}/{data.lights_total} an
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.lights.length > 0 ? (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{data.lights.map((light) => {
|
||||
const isOn = light.state === "on";
|
||||
return (
|
||||
<div
|
||||
key={light.entity_id}
|
||||
className={`
|
||||
flex flex-col items-center gap-1.5 px-2 py-2.5 rounded-xl border transition-colors
|
||||
${
|
||||
isOn
|
||||
? "bg-amber-500/10 border-amber-500/20"
|
||||
: "bg-white/[0.02] border-white/[0.04]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-colors ${
|
||||
isOn
|
||||
? "bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.5)]"
|
||||
: "bg-slate-700"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[10px] text-center text-slate-400 leading-tight truncate w-full">
|
||||
{light.name}
|
||||
</span>
|
||||
{isOn && light.brightness > 0 && (
|
||||
<span className="text-[9px] text-amber-400/70">
|
||||
{Math.round((light.brightness / 255) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-600">Keine Lichter konfiguriert</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Covers Section */}
|
||||
{data.covers.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<ArrowUp className="w-3.5 h-3.5 text-violet-400/70" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Rollos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{data.covers.map((cover) => {
|
||||
const isOpen = cover.state === "open";
|
||||
const isClosed = cover.state === "closed";
|
||||
return (
|
||||
<div
|
||||
key={cover.entity_id}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/[0.04]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpen ? (
|
||||
<ArrowUp className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : isClosed ? (
|
||||
<ArrowDown className="w-3.5 h-3.5 text-slate-500" />
|
||||
) : (
|
||||
<ArrowDown className="w-3.5 h-3.5 text-amber-400" />
|
||||
)}
|
||||
<span className="text-xs text-slate-300">{cover.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{cover.position > 0 && (
|
||||
<span className="text-[10px] text-slate-500" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{cover.position}%
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] font-medium ${
|
||||
isOpen ? "text-emerald-400" : isClosed ? "text-slate-500" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{isOpen ? "Offen" : isClosed ? "Zu" : cover.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Temperature Sensors Section */}
|
||||
{data.sensors.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Thermometer className="w-3.5 h-3.5 text-violet-400/70" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Temperaturen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{data.sensors.map((sensor) => (
|
||||
<div
|
||||
key={sensor.entity_id}
|
||||
className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/[0.04]"
|
||||
>
|
||||
<span className="text-xs text-slate-300">{sensor.name}</span>
|
||||
<span
|
||||
className="text-sm font-semibold text-white"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{typeof sensor.state === "number"
|
||||
? sensor.state.toFixed(1)
|
||||
: sensor.state}
|
||||
<span className="text-xs text-slate-500 ml-0.5">{sensor.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
web/src/components/HourlyForecast.tsx
Normal file
93
web/src/components/HourlyForecast.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { Droplets } from "lucide-react";
|
||||
import type { HourlySlot } from "../api";
|
||||
|
||||
interface HourlyForecastProps {
|
||||
slots: HourlySlot[];
|
||||
}
|
||||
|
||||
export default function HourlyForecast({ slots }: HourlyForecastProps) {
|
||||
if (!slots || slots.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="glass-card p-4 animate-fade-in">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3 px-1">
|
||||
Stundenverlauf
|
||||
</h3>
|
||||
|
||||
{/* Horizontal scroll container - hidden scrollbar via globals.css */}
|
||||
<div
|
||||
className="flex gap-2 overflow-x-auto pb-1"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{slots.map((slot, i) => {
|
||||
const hour = formatHour(slot.time);
|
||||
const isNow = i === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.time}
|
||||
className={`
|
||||
flex flex-col items-center gap-1.5 min-w-[4.25rem] px-2.5 py-3 rounded-xl
|
||||
border transition-colors duration-200
|
||||
${
|
||||
isNow
|
||||
? "bg-white/[0.06] border-cyan-500/20"
|
||||
: "bg-white/[0.02] border-white/[0.04] hover:bg-white/[0.04]"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Time label */}
|
||||
<span
|
||||
className={`text-[11px] font-medium ${isNow ? "text-cyan-400" : "text-slate-500"}`}
|
||||
>
|
||||
{isNow ? "Jetzt" : hour}
|
||||
</span>
|
||||
|
||||
{/* Weather icon */}
|
||||
<span className="text-xl select-none" role="img" aria-label="weather">
|
||||
{slot.icon}
|
||||
</span>
|
||||
|
||||
{/* Temperature */}
|
||||
<span
|
||||
className="text-sm font-bold text-white"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{Math.round(slot.temp)}°
|
||||
</span>
|
||||
|
||||
{/* Precipitation bar */}
|
||||
{slot.precip_chance > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="w-2.5 h-2.5 text-blue-400/60" />
|
||||
<span className="text-[10px] text-blue-400/80">
|
||||
{slot.precip_chance}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Precip bar visual */}
|
||||
<div className="w-full progress-bar mt-0.5">
|
||||
<div
|
||||
className="progress-fill bg-blue-500/50"
|
||||
style={{ width: `${Math.min(slot.precip_chance, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracts "HH:mm" from an ISO time string or "HH:00" format. */
|
||||
function formatHour(time: string): string {
|
||||
try {
|
||||
const d = new Date(time);
|
||||
if (isNaN(d.getTime())) return time;
|
||||
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return time;
|
||||
}
|
||||
}
|
||||
144
web/src/components/NewsGrid.tsx
Normal file
144
web/src/components/NewsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
web/src/components/ServerCard.tsx
Normal file
241
web/src/components/ServerCard.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { useState } from "react";
|
||||
import { Server, Cpu, HardDrive, Wifi, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react";
|
||||
import type { ServerStats } from "../api";
|
||||
|
||||
interface ServerCardProps {
|
||||
server: ServerStats;
|
||||
}
|
||||
|
||||
/** Return Tailwind text colour class based on percentage thresholds. */
|
||||
function usageColor(pct: number): string {
|
||||
if (pct >= 80) return "text-red-400";
|
||||
if (pct >= 60) return "text-amber-400";
|
||||
return "text-emerald-400";
|
||||
}
|
||||
|
||||
/** Return SVG stroke colour (hex) based on percentage thresholds. */
|
||||
function usageStroke(pct: number): string {
|
||||
if (pct >= 80) return "#f87171"; // red-400
|
||||
if (pct >= 60) return "#fbbf24"; // amber-400
|
||||
return "#34d399"; // emerald-400
|
||||
}
|
||||
|
||||
/** Return track background colour based on percentage thresholds. */
|
||||
function usageBarBg(pct: number): string {
|
||||
if (pct >= 80) return "bg-red-500/70";
|
||||
if (pct >= 60) return "bg-amber-500/70";
|
||||
return "bg-emerald-500/70";
|
||||
}
|
||||
|
||||
export default function ServerCard({ server }: ServerCardProps) {
|
||||
const [dockerExpanded, setDockerExpanded] = useState(false);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
const cpuPct = Math.round(server.cpu.usage_pct);
|
||||
const ramPct = Math.round(server.ram.pct);
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5 animate-fade-in bg-gradient-to-br from-emerald-500/[0.04] to-transparent">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
||||
<Server className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{server.name}</h3>
|
||||
<p className="text-[10px] text-slate-500">{server.host}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatusDot online={server.online} />
|
||||
</div>
|
||||
|
||||
{!server.online ? (
|
||||
<div className="flex items-center justify-center py-8 text-slate-500 text-sm gap-2">
|
||||
<WifiOff className="w-4 h-4" />
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* CPU Section */}
|
||||
<div className="flex items-center gap-4">
|
||||
<CpuRing pct={cpuPct} size={64} strokeWidth={5} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Cpu className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
CPU
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-xl font-bold ${usageColor(cpuPct)}`} style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{cpuPct}%
|
||||
</span>
|
||||
{server.cpu.temp_c !== null && (
|
||||
<span className="text-xs text-slate-500">
|
||||
{Math.round(server.cpu.temp_c)}°C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 mt-0.5">
|
||||
{server.cpu.cores} Kerne
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
RAM
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold ${usageColor(ramPct)}`} style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{ramPct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className={`progress-fill ${usageBarBg(ramPct)}`}
|
||||
style={{ width: `${ramPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-slate-600 mt-1.5">
|
||||
{server.ram.used_gb.toFixed(1)} / {server.ram.total_gb.toFixed(1)} GB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
{server.uptime && (
|
||||
<p className="text-[10px] text-slate-600">
|
||||
Uptime: <span className="text-slate-400">{server.uptime}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Docker Section */}
|
||||
{server.docker && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setDockerExpanded(!dockerExpanded)}
|
||||
className="flex items-center justify-between w-full group"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Box className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Docker
|
||||
</span>
|
||||
<span className="badge bg-emerald-500/15 text-emerald-300 ml-1">
|
||||
{server.docker.running}
|
||||
</span>
|
||||
</div>
|
||||
{dockerExpanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{dockerExpanded && server.docker.containers.length > 0 && (
|
||||
<div className="mt-2.5 space-y-1 max-h-48 overflow-y-auto">
|
||||
{server.docker.containers.map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white/[0.02] border border-white/[0.04]"
|
||||
>
|
||||
<span className="text-xs text-slate-300 truncate mr-2">{c.name}</span>
|
||||
<span
|
||||
className={`text-[10px] font-medium ${
|
||||
c.status.toLowerCase().includes("up")
|
||||
? "text-emerald-400"
|
||||
: "text-red-400"
|
||||
}`}
|
||||
>
|
||||
{c.status.toLowerCase().includes("up") ? "Running" : c.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Green/Red online indicator dot. */
|
||||
function StatusDot({ online }: { online: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
online ? "bg-emerald-400" : "bg-red-400"
|
||||
}`}
|
||||
/>
|
||||
{online && (
|
||||
<div className="absolute inset-0 w-2 h-2 rounded-full bg-emerald-400 animate-ping opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium ${online ? "text-emerald-400" : "text-red-400"}`}>
|
||||
{online ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** SVG circular progress ring for CPU usage. */
|
||||
function CpuRing({
|
||||
pct,
|
||||
size,
|
||||
strokeWidth,
|
||||
}: {
|
||||
pct: number;
|
||||
size: number;
|
||||
strokeWidth: number;
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (pct / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
{/* Track */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(148,163,184,0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={usageStroke(pct)}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: "stroke-dashoffset 0.7s ease-out, stroke 0.3s ease" }}
|
||||
/>
|
||||
</svg>
|
||||
{/* Center icon */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Cpu className={`w-4 h-4 ${usageColor(pct)} opacity-50`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
web/src/components/TasksCard.tsx
Normal file
183
web/src/components/TasksCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
web/src/components/WeatherCard.tsx
Normal file
115
web/src/components/WeatherCard.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { Thermometer, Droplets, Wind, CloudOff } from "lucide-react";
|
||||
import type { WeatherData } from "../api";
|
||||
|
||||
interface WeatherCardProps {
|
||||
data: WeatherData;
|
||||
accent: "cyan" | "amber";
|
||||
}
|
||||
|
||||
const accentMap = {
|
||||
cyan: {
|
||||
gradient: "from-cyan-500/10 to-cyan-900/5",
|
||||
text: "text-cyan-400",
|
||||
border: "border-cyan-500/20",
|
||||
badge: "bg-cyan-500/15 text-cyan-300",
|
||||
statIcon: "text-cyan-400/70",
|
||||
ring: "ring-cyan-500/10",
|
||||
},
|
||||
amber: {
|
||||
gradient: "from-amber-500/10 to-amber-900/5",
|
||||
text: "text-amber-400",
|
||||
border: "border-amber-500/20",
|
||||
badge: "bg-amber-500/15 text-amber-300",
|
||||
statIcon: "text-amber-400/70",
|
||||
ring: "ring-amber-500/10",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function WeatherCard({ data, accent }: WeatherCardProps) {
|
||||
const a = accentMap[accent];
|
||||
|
||||
if (data.error) {
|
||||
return (
|
||||
<div className="glass-card p-5 animate-fade-in">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<CloudOff className="w-5 h-5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Wetter nicht verfuegbar</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">{data.location || "Unbekannt"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`glass-card p-5 animate-fade-in bg-gradient-to-br ${a.gradient}`}>
|
||||
{/* Header: location badge */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className={`badge ${a.badge}`}>{data.location}</span>
|
||||
</div>
|
||||
|
||||
{/* Main row: icon + temp */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className="text-5xl font-extrabold text-white tracking-tighter"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{Math.round(data.temp)}
|
||||
</span>
|
||||
<span className="text-2xl font-light text-slate-400">°</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-1 capitalize">{data.description}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-5xl select-none" role="img" aria-label="weather">
|
||||
{data.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stat grid */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatItem
|
||||
icon={<Thermometer className={`w-3.5 h-3.5 ${a.statIcon}`} />}
|
||||
label="Gefuehlt"
|
||||
value={`${Math.round(data.feels_like)}\u00B0`}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<Droplets className={`w-3.5 h-3.5 ${a.statIcon}`} />}
|
||||
label="Feuchte"
|
||||
value={`${data.humidity}%`}
|
||||
/>
|
||||
<StatItem
|
||||
icon={<Wind className={`w-3.5 h-3.5 ${a.statIcon}`} />}
|
||||
label="Wind"
|
||||
value={`${Math.round(data.wind_kmh)} km/h`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5 rounded-xl bg-white/[0.03] border border-white/[0.04] px-2 py-2.5">
|
||||
{icon}
|
||||
<span
|
||||
className="text-sm font-semibold text-white"
|
||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
web/src/hooks/useDashboard.ts
Normal file
57
web/src/hooks/useDashboard.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { DashboardData } from "../api";
|
||||
import { fetchAll } from "../api";
|
||||
import { useWebSocket } from "./useWebSocket";
|
||||
import { MOCK_DATA } from "../mockData";
|
||||
|
||||
/**
|
||||
* Main dashboard hook.
|
||||
* Fetches initial data via REST, then switches to WebSocket for live updates.
|
||||
* Falls back to mock data in development when the backend is unavailable.
|
||||
*/
|
||||
export function useDashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [usingMock, setUsingMock] = useState(false);
|
||||
|
||||
const { data: wsData, connected } = useWebSocket();
|
||||
|
||||
// Initial REST fetch
|
||||
const loadInitial = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await fetchAll();
|
||||
setData(result);
|
||||
setUsingMock(false);
|
||||
} catch {
|
||||
// Fall back to mock data in dev
|
||||
if (import.meta.env.DEV) {
|
||||
setData({ ...MOCK_DATA, timestamp: new Date().toISOString() });
|
||||
setUsingMock(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setError("Verbindung zum Server fehlgeschlagen");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
// WebSocket updates override REST data
|
||||
useEffect(() => {
|
||||
if (wsData) {
|
||||
setData(wsData);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setUsingMock(false);
|
||||
}
|
||||
}, [wsData]);
|
||||
|
||||
return { data, loading, error, connected: connected || usingMock, refresh: loadInitial };
|
||||
}
|
||||
79
web/src/hooks/useWebSocket.ts
Normal file
79
web/src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { DashboardData } from "../api";
|
||||
|
||||
const WS_RECONNECT_MS = 5_000;
|
||||
const WS_PING_INTERVAL_MS = 15_000;
|
||||
|
||||
export function useWebSocket() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(true);
|
||||
ws.send("ping");
|
||||
|
||||
pingRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send("ping");
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
if (!mountedRef.current) return;
|
||||
try {
|
||||
const parsed = JSON.parse(ev.data) as DashboardData;
|
||||
setData(parsed);
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(false);
|
||||
cleanup();
|
||||
reconnectRef.current = setTimeout(connect, WS_RECONNECT_MS);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pingRef.current) {
|
||||
clearInterval(pingRef.current);
|
||||
pingRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
cleanup();
|
||||
if (reconnectRef.current) clearTimeout(reconnectRef.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect, cleanup]);
|
||||
|
||||
return { data, connected };
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles/globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
175
web/src/mockData.ts
Normal file
175
web/src/mockData.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/** Mock data for development preview when backend is unavailable. */
|
||||
import type { DashboardData } from "./api";
|
||||
|
||||
export const MOCK_DATA: DashboardData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
weather: {
|
||||
primary: {
|
||||
location: "Leverkusen",
|
||||
temp: 8,
|
||||
feels_like: 5,
|
||||
humidity: 72,
|
||||
wind_kmh: 18,
|
||||
description: "Teilweise bewölkt",
|
||||
icon: "⛅",
|
||||
forecast: [
|
||||
{ date: "2026-03-02", max_temp: 10, min_temp: 3, icon: "⛅", description: "Partly cloudy" },
|
||||
{ date: "2026-03-03", max_temp: 12, min_temp: 5, icon: "🌤️", description: "Sunny intervals" },
|
||||
{ date: "2026-03-04", max_temp: 7, min_temp: 1, icon: "🌧️", description: "Rain" },
|
||||
],
|
||||
},
|
||||
secondary: {
|
||||
location: "Rab, Kroatien",
|
||||
temp: 16,
|
||||
feels_like: 15,
|
||||
humidity: 58,
|
||||
wind_kmh: 12,
|
||||
description: "Sonnig",
|
||||
icon: "☀️",
|
||||
forecast: [
|
||||
{ date: "2026-03-02", max_temp: 18, min_temp: 10, icon: "☀️", description: "Sunny" },
|
||||
{ date: "2026-03-03", max_temp: 19, min_temp: 11, icon: "🌤️", description: "Mostly sunny" },
|
||||
{ date: "2026-03-04", max_temp: 17, min_temp: 9, icon: "⛅", description: "Partly cloudy" },
|
||||
],
|
||||
},
|
||||
hourly: [
|
||||
{ time: "14:00", temp: 8, icon: "⛅", precip_chance: 10 },
|
||||
{ time: "15:00", temp: 9, icon: "🌤️", precip_chance: 5 },
|
||||
{ time: "16:00", temp: 8, icon: "⛅", precip_chance: 15 },
|
||||
{ time: "17:00", temp: 7, icon: "☁️", precip_chance: 25 },
|
||||
{ time: "18:00", temp: 6, icon: "☁️", precip_chance: 30 },
|
||||
{ time: "19:00", temp: 5, icon: "🌧️", precip_chance: 55 },
|
||||
{ time: "20:00", temp: 4, icon: "🌧️", precip_chance: 60 },
|
||||
{ time: "21:00", temp: 4, icon: "☁️", precip_chance: 35 },
|
||||
],
|
||||
},
|
||||
news: {
|
||||
articles: [
|
||||
{ id: 1, source: "tagesschau", title: "Bundesregierung beschließt neues Infrastrukturpaket", url: "#", category: "politik", published_at: new Date(Date.now() - 3600000).toISOString() },
|
||||
{ id: 2, source: "heise", title: "Intel stellt neue Arc-Grafikkarten vor: Battlemage kommt im Q2", url: "#", category: "tech", published_at: new Date(Date.now() - 7200000).toISOString() },
|
||||
{ id: 3, source: "spiegel", title: "DAX erreicht neues Allzeithoch trotz geopolitischer Spannungen", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 10800000).toISOString() },
|
||||
{ id: 4, source: "google_finance", title: "NVIDIA reports record quarterly revenue driven by AI demand", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 14400000).toISOString() },
|
||||
{ id: 5, source: "tagesschau", title: "Klimagipfel: Neue Vereinbarungen zum CO2-Ausstoß beschlossen", url: "#", category: "politik", published_at: new Date(Date.now() - 18000000).toISOString() },
|
||||
{ id: 6, source: "heise", title: "Linux 6.14 Kernel bringt massiven Performance-Boost für AMD Zen 5", url: "#", category: "tech", published_at: new Date(Date.now() - 21600000).toISOString() },
|
||||
{ id: 7, source: "google_de_finance", title: "Rheinmetall-Aktie steigt um 8% nach neuem NATO-Auftrag", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 25200000).toISOString() },
|
||||
{ id: 8, source: "spiegel", title: "Studie: Homeoffice bleibt auch 2026 die beliebteste Arbeitsform", url: "#", category: "allgemein", published_at: new Date(Date.now() - 28800000).toISOString() },
|
||||
{ id: 9, source: "heise", title: "Docker Desktop 5.0: Neue Container-Management-Features im Überblick", url: "#", category: "tech", published_at: new Date(Date.now() - 32400000).toISOString() },
|
||||
{ id: 10, source: "tagesschau", title: "Europäische Zentralbank senkt Leitzins um 0.25 Prozentpunkte", url: "#", category: "wirtschaft", published_at: new Date(Date.now() - 36000000).toISOString() },
|
||||
{ id: 11, source: "google_defense", title: "NATO increases defense spending targets for 2027", url: "#", category: "politik", published_at: new Date(Date.now() - 39600000).toISOString() },
|
||||
{ id: 12, source: "spiegel", title: "Unraid 7.0: Das neue Server-Betriebssystem im Test", url: "#", category: "tech", published_at: new Date(Date.now() - 43200000).toISOString() },
|
||||
],
|
||||
total: 12,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
},
|
||||
servers: {
|
||||
servers: [
|
||||
{
|
||||
name: "Daddelolymp",
|
||||
host: "10.10.10.10",
|
||||
online: true,
|
||||
uptime: "42 days, 7:23",
|
||||
cpu: { usage_pct: 38, cores: 16, temp_c: 52 },
|
||||
ram: { used_gb: 24.3, total_gb: 32, pct: 76 },
|
||||
array: {
|
||||
status: "normal",
|
||||
disks: [
|
||||
{ name: "Disk 1", status: "active", size: "8 TB", used: "5.2 TB" },
|
||||
{ name: "Disk 2", status: "active", size: "8 TB", used: "6.1 TB" },
|
||||
{ name: "Disk 3", status: "active", size: "4 TB", used: "2.8 TB" },
|
||||
{ name: "Parity", status: "active", size: "8 TB", used: "-" },
|
||||
{ name: "Cache", status: "active", size: "1 TB", used: "320 GB" },
|
||||
],
|
||||
},
|
||||
docker: {
|
||||
running: 14,
|
||||
containers: [
|
||||
{ name: "gitlab", status: "running", image: "gitlab/gitlab-ce" },
|
||||
{ name: "n8n", status: "running", image: "n8nio/n8n" },
|
||||
{ name: "postgres", status: "running", image: "postgres:15" },
|
||||
{ name: "daily-briefing", status: "running", image: "daily-briefing" },
|
||||
{ name: "jukebox-vibe", status: "running", image: "jukebox-vibe" },
|
||||
{ name: "redis", status: "running", image: "redis:7" },
|
||||
{ name: "vikunja", status: "running", image: "vikunja/vikunja" },
|
||||
{ name: "nginx", status: "running", image: "nginx:alpine" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Moneyboy",
|
||||
host: "192.168.1.100",
|
||||
online: true,
|
||||
uptime: "12 days, 14:52",
|
||||
cpu: { usage_pct: 12, cores: 10, temp_c: 41 },
|
||||
ram: { used_gb: 8.7, total_gb: 16, pct: 54 },
|
||||
array: {
|
||||
status: "normal",
|
||||
disks: [
|
||||
{ name: "Disk 1", status: "active", size: "4 TB", used: "1.8 TB" },
|
||||
{ name: "Disk 2", status: "active", size: "4 TB", used: "2.1 TB" },
|
||||
{ name: "Cache", status: "active", size: "500 GB", used: "180 GB" },
|
||||
],
|
||||
},
|
||||
docker: {
|
||||
running: 6,
|
||||
containers: [
|
||||
{ name: "freqtrade", status: "running", image: "freqtradeorg/freqtrade" },
|
||||
{ name: "plex", status: "running", image: "plexinc/pms-docker" },
|
||||
{ name: "nextcloud", status: "running", image: "nextcloud" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
ha: {
|
||||
online: true,
|
||||
lights: [
|
||||
{ entity_id: "light.wohnzimmer", name: "Wohnzimmer", state: "on", brightness: 80 },
|
||||
{ entity_id: "light.schlafzimmer", name: "Schlafzimmer", state: "off", brightness: 0 },
|
||||
{ entity_id: "light.kueche", name: "Küche", state: "on", brightness: 100 },
|
||||
{ entity_id: "light.flur", name: "Flur", state: "off", brightness: 0 },
|
||||
{ entity_id: "light.badezimmer", name: "Badezimmer", state: "on", brightness: 60 },
|
||||
{ entity_id: "light.buero", name: "Büro", state: "off", brightness: 0 },
|
||||
{ entity_id: "light.balkon", name: "Balkon", state: "off", brightness: 0 },
|
||||
{ entity_id: "light.gaestezimmer", name: "Gästezimmer", state: "off", brightness: 0 },
|
||||
],
|
||||
covers: [
|
||||
{ entity_id: "cover.wohnzimmer", name: "Wohnzimmer Rollo", state: "open", position: 100 },
|
||||
{ entity_id: "cover.schlafzimmer", name: "Schlafzimmer Rollo", state: "closed", position: 0 },
|
||||
{ entity_id: "cover.kueche", name: "Küche Rollo", state: "open", position: 75 },
|
||||
],
|
||||
sensors: [
|
||||
{ entity_id: "sensor.temp_wohnzimmer", name: "Wohnzimmer", state: 21.5, unit: "°C" },
|
||||
{ entity_id: "sensor.temp_aussen", name: "Außen", state: 7.8, unit: "°C" },
|
||||
{ entity_id: "sensor.temp_schlafzimmer", name: "Schlafzimmer", state: 19.2, unit: "°C" },
|
||||
],
|
||||
lights_on: 3,
|
||||
lights_total: 8,
|
||||
},
|
||||
tasks: {
|
||||
private: {
|
||||
open: [
|
||||
{ id: 101, title: "Garten winterfest machen", done: false, priority: 3, project_name: "Haus & Garten", due_date: "2026-03-05" },
|
||||
{ id: 102, title: "Heizung warten lassen", done: false, priority: 2, project_name: "Haus & Garten", due_date: "2026-03-10" },
|
||||
{ id: 103, title: "Projekt-Bericht abgeben", done: false, priority: 4, project_name: "Jugendeinrichtung", due_date: "2026-03-03" },
|
||||
{ id: 104, title: "Elternabend planen", done: false, priority: 1, project_name: "Jugendeinrichtung", due_date: null },
|
||||
{ id: 105, title: "Neue Pflanzen bestellen", done: false, priority: 1, project_name: "Haus & Garten", due_date: null },
|
||||
],
|
||||
done: [
|
||||
{ id: 106, title: "Müll rausbringen", done: true, priority: 1, project_name: "Haus & Garten", due_date: null },
|
||||
],
|
||||
open_count: 5,
|
||||
},
|
||||
sams: {
|
||||
open: [
|
||||
{ id: 201, title: "Daily Briefing Dashboard refactoren", done: false, priority: 4, project_name: "OpenClaw AI", due_date: "2026-03-02" },
|
||||
{ id: 202, title: "n8n Workflows aufräumen", done: false, priority: 3, project_name: "OpenClaw AI", due_date: null },
|
||||
{ id: 203, title: "Trading Bot backtesting", done: false, priority: 2, project_name: "Sam's Wunderwelt", due_date: null },
|
||||
],
|
||||
done: [
|
||||
{ id: 204, title: "Jukebox Drag & Drop Feature", done: true, priority: 3, project_name: "OpenClaw AI", due_date: null },
|
||||
],
|
||||
open_count: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
126
web/src/styles/globals.css
Normal file
126
web/src/styles/globals.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
background: linear-gradient(135deg, #0a0e1a 0%, #0f172a 50%, #0c1220 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-card {
|
||||
@apply relative overflow-hidden rounded-2xl border border-white/[0.06]
|
||||
bg-white/[0.03] backdrop-blur-md shadow-xl shadow-black/20;
|
||||
}
|
||||
|
||||
.glass-card-hover {
|
||||
@apply glass-card transition-all duration-300
|
||||
hover:border-white/[0.12] hover:bg-white/[0.05]
|
||||
hover:shadow-2xl hover:shadow-black/30
|
||||
hover:-translate-y-0.5;
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.04) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.accent-glow {
|
||||
position: relative;
|
||||
}
|
||||
.accent-glow::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.accent-glow:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
@apply text-3xl font-bold tracking-tight;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
@apply text-xs font-medium uppercase tracking-wider text-slate-400;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px]
|
||||
font-semibold uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply h-1.5 rounded-full bg-slate-800 overflow-hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full rounded-full transition-all duration-700 ease-out;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
@apply px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
|
||||
cursor-pointer select-none;
|
||||
}
|
||||
.category-tab.active {
|
||||
@apply bg-white/10 text-white;
|
||||
}
|
||||
.category-tab:not(.active) {
|
||||
@apply text-slate-400 hover:text-slate-200 hover:bg-white/5;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
34
web/tailwind.config.js
Normal file
34
web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: {
|
||||
50: "rgba(255,255,255,0.05)",
|
||||
100: "rgba(255,255,255,0.08)",
|
||||
200: "rgba(255,255,255,0.12)",
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: "2px",
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fadeIn 0.5s ease-out",
|
||||
"slide-up": "slideUp 0.4s ease-out",
|
||||
"pulse-slow": "pulse 3s ease-in-out infinite",
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
"0%": { opacity: "0", transform: "translateY(8px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
slideUp: {
|
||||
"0%": { opacity: "0", transform: "translateY(16px)" },
|
||||
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
21
web/tsconfig.json
Normal file
21
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
23
web/vite.config.ts
Normal file
23
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/ws": {
|
||||
target: "ws://localhost:8080",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue