feat(dark-mode-toggle): Dark-Mode mit System-Preference + localStorage + toggle [tsc:ok]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 05:21:08 +02:00
parent 38d9258912
commit 596e035a00
7 changed files with 313 additions and 11 deletions

View File

@ -4,7 +4,8 @@
"csv-export-time-entries",
"error-boundary",
"dashboard-charts",
"api-client-phase4"
"api-client-phase4",
"router-with-admin"
],
"current_feature": "router-with-admin",
"started_at": "2026-05-23T05:10:51.482879"

5
.phase5-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "dark-mode-toggle",
"started_at": "2026-05-23T05:20:11.407108"
}

View File

@ -527,3 +527,25 @@ undefined
- `05:18:20` **INFO** wrote 3580 chars in 29.7s (attempt 1)
- `05:18:20` **INFO** Running tsc --noEmit on api…
- `05:18:21` **INFO** tsc clean ✓
- `05:18:21` **INFO** Committed feature router-with-admin
- `05:18:22` **INFO** Pushed: rc=0
## Phase-4 Run beendet (2026-05-23 05:18:22)
- `05:18:22` **INFO** OK: 6, Attempted: 0, Total: 6
## 🚀 Phase-5 Codegen-Run gestartet (2026-05-23 05:20:11)
- `05:20:11` **INFO** Features: 6
## Phase-3 Feature: dark-mode-toggle (2026-05-23 05:20:11)
- `05:20:11` **INFO** Description: Dark-Mode mit System-Preference + localStorage + toggle
- `05:20:11` **INFO** Generating apps/web/src/lib/theme.ts (Theme-Hook + Util. useTheme() returns {theme:'light'|'dark', setTheme,…)
- `05:20:23` **INFO** wrote 1465 chars in 12.0s (attempt 1)
- `05:20:23` **INFO** Generating apps/web/tailwind.config.ts (ERWEITERT — füge `darkMode: 'class'` hinzu. Behalte content + theme.ex…)
- `05:20:26` **INFO** wrote 315 chars in 3.4s (attempt 1)
- `05:20:26` **INFO** Generating apps/web/src/components/Nav.tsx (ERWEITERT — füge Theme-Toggle-Button rechts (Sun/Moon Icon von lucide-…)
- `05:21:06` **INFO** wrote 4485 chars in 40.0s (attempt 1)
- `05:21:06` **INFO** Running tsc --noEmit on api…
- `05:21:08` **INFO** tsc clean ✓

View File

@ -7,13 +7,17 @@ import {
FolderKanban,
User,
LogOut,
ShieldCheck
ShieldCheck,
Sun,
Moon
} from "lucide-react"
import { useQuery } from "@tanstack/react-query"
import { api } from "../lib/api"
import { useTheme } from "../hooks/useTheme"
export default function Nav() {
const location = useLocation()
const { theme, toggleTheme } = useTheme()
const { data: user } = useQuery({
queryKey: ['me'],
@ -37,13 +41,13 @@ export default function Nav() {
}
return (
<nav className="bg-white border-b border-gray-200 sticky top-0 z-50">
<nav className="bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-800 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center gap-8">
<Link
to="/"
className="text-xl font-bold text-indigo-600 mr-4 flex items-center gap-2"
className="text-xl font-bold text-indigo-600 dark:text-indigo-400 mr-4 flex items-center gap-2"
>
EmberClone
</Link>
@ -58,8 +62,8 @@ export default function Nav() {
to={item.to}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? "bg-indigo-50 text-indigo-700"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<Icon className="w-4 h-4" />
@ -73,8 +77,8 @@ export default function Nav() {
to="/admin"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
location.pathname === "/admin"
? "bg-indigo-50 text-indigo-700"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<ShieldCheck className="w-4 h-4" />
@ -85,12 +89,20 @@ export default function Nav() {
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleTheme}
className="p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:text-slate-400 dark:hover:bg-slate-800 transition-colors"
title="Toggle Theme"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<Link
to="/profile"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
location.pathname === "/profile"
? "bg-indigo-50 text-indigo-700"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
}`}
>
<User className="w-4 h-4" />
@ -99,7 +111,7 @@ export default function Nav() {
<button
onClick={handleLogout}
className="flex items-center gap-2 text-sm font-medium text-gray-500 hover:text-red-600 transition-colors px-3 py-2"
className="flex items-center gap-2 text-sm font-medium text-gray-500 hover:text-red-600 dark:text-slate-400 dark:hover:text-red-400 transition-colors px-3 py-2"
>
<LogOut className="w-4 h-4" />
Logout

54
apps/web/src/lib/theme.ts Normal file
View File

@ -0,0 +1,54 @@
import { create contexts, createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('theme') as Theme | null;
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
});
useEffect(() => {
const root = window.document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', theme);
}, [theme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
};
const toggleTheme = () => {
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@ -5,6 +5,7 @@ export default {
"./index.html",
"./src/**/*.{ts,tsx}"
],
darkMode: 'class',
theme: {
extend: {
colors: {

207
scripts/phase5_features.py Normal file
View File

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Phase-5: dark-mode, settings, calendar, customer/project details."""
from __future__ import annotations
import asyncio
import datetime
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402
from phase3_features import run_feature_v2 # noqa: E402
PHASE5_STATE = ROOT / ".phase5-state.json"
FEATURES: list[Feature] = [
Feature(
name="dark-mode-toggle",
description="Dark-Mode mit System-Preference + localStorage + toggle",
files=[
FileGen(
path="apps/web/src/lib/theme.ts",
purpose=(
"Theme-Hook + Util. useTheme() returns {theme:'light'|'dark', setTheme, toggle}. "
"Persist in localStorage 'theme'. Initial: System-pref via matchMedia. "
"Side-effect: setzt document.documentElement.classList toggle('dark')."
),
),
FileGen(
path="apps/web/tailwind.config.ts",
purpose=(
"ERWEITERT — füge `darkMode: 'class'` hinzu. Behalte content + theme.extend.colors.ember."
),
refs=["apps/web/tailwind.config.ts"],
),
FileGen(
path="apps/web/src/components/Nav.tsx",
purpose=(
"ERWEITERT — füge Theme-Toggle-Button rechts (Sun/Moon Icon von lucide-react, useTheme hook). "
"Behalte alle bestehenden Links + Logout + Profile + Admin (bei admin-role)."
),
refs=["apps/web/src/components/Nav.tsx"],
),
],
),
Feature(
name="customer-detail-page",
description="Customer-Detail: zeigt Projekte + letzte Time-Entries des Kunden",
files=[
FileGen(
path="apps/api/src/routes/customers.ts",
purpose=(
"ERWEITERT — behalte CRUD. Füge GET /:id/projects (alle Projekte zum Kunden) und "
"GET /:id/time-entries (alle TimeEntries deren projectId zu einem Projekt dieses Kunden gehört, "
"letzte 50 sortiert by startTime desc)."
),
refs=["apps/api/src/routes/customers.ts"],
),
FileGen(
path="apps/web/src/pages/CustomerDetail.tsx",
purpose=(
"CustomerDetail-Page. Liest customerId aus URL-Param. Zeigt: Customer-Header (name, status). "
"Section: Projekte (Liste, Klick → /projects). Section: Letzte 20 TimeEntries (kompakte Tabelle). "
"Verwende api.getCustomerProjects(id), api.getCustomerTimeEntries(id)."
),
refs=["apps/web/src/pages/Customers.tsx"],
),
],
),
Feature(
name="project-detail-page",
description="Project-Detail: zeigt Customer + alle TimeEntries des Projekts",
files=[
FileGen(
path="apps/web/src/pages/ProjectDetail.tsx",
purpose=(
"ProjectDetail-Page. Header: name + linked customer. Section: TimeEntries für dieses Projekt "
"(alle, mit Gesamt-Stunden-Summary). Verwende api.getProject(id), api.listTimeEntries({projectId:id})."
),
refs=["apps/web/src/pages/Projects.tsx"],
),
],
),
Feature(
name="settings-page",
description="App-Settings (workspace name, default-billable, etc.)",
files=[
FileGen(
path="apps/api/src/db/schema.ts",
purpose=(
"ERWEITERT — behalte alle bestehenden Tabellen. Füge neue Tabelle `appSettings` (pgTable 'app_settings'): "
"id (singleton uuid, primary), workspaceName (text default 'EmberClone'), defaultBillable (boolean default true), "
"weekStart (integer default 1 für Monday), updatedAt (timestamp.notNull().defaultNow())."
),
refs=["apps/api/src/db/schema.ts"],
),
FileGen(
path="apps/api/src/routes/settings.ts",
purpose=(
"Fastify-Plugin /api/settings. GET / (current settings, lazy-init if none exist). "
"PATCH / (admin-only update). Auth required, role-check für PATCH."
),
refs=["apps/api/src/routes/users.ts"],
),
FileGen(
path="apps/web/src/pages/Settings.tsx",
purpose=(
"Settings-Page. Form mit workspaceName, defaultBillable (checkbox), weekStart (select Mon/Sun). "
"Admin-only sichtbar (sonst Forbidden). Submit → api.updateSettings()."
),
refs=["apps/web/src/pages/Profile.tsx"],
),
],
),
Feature(
name="api-client-phase5",
description="API um customer-detail, project-detail, settings, theme erweitern",
files=[
FileGen(
path="apps/web/src/lib/api.ts",
purpose=(
"ERWEITERT — behalte ALLES. Füge: getCustomerProjects(id), getCustomerTimeEntries(id), "
"getProject(id), getSettings(), updateSettings({workspaceName?, defaultBillable?, weekStart?})."
),
refs=["apps/web/src/lib/api.ts"],
),
],
),
Feature(
name="router-phase5",
description="App.tsx + Nav um neue Routen erweitern + db-migrate nicht vergessen",
files=[
FileGen(
path="apps/api/src/routes/index.ts",
purpose=(
"ERWEITERT — behalte alle bestehenden registrations. Füge `settingsRoutes` mit prefix '/api/settings' hinzu."
),
refs=["apps/api/src/routes/index.ts"],
),
FileGen(
path="apps/web/src/App.tsx",
purpose=(
"ERWEITERT — füge Routes /customers/$id (CustomerDetail), /projects/$id (ProjectDetail), "
"/settings (Settings, admin-only). Behalte ErrorBoundary + ToastProvider + alle bestehenden Routes."
),
refs=["apps/web/src/App.tsx"],
),
FileGen(
path="apps/web/src/components/Nav.tsx",
purpose=(
"ERWEITERT — füge Settings-Link bei admin-role + Theme-Toggle. Behalte alle bestehenden Links."
),
refs=["apps/web/src/components/Nav.tsx"],
),
],
),
]
def load_state() -> dict:
if PHASE5_STATE.exists():
return json.loads(PHASE5_STATE.read_text())
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
def save_state(state: dict) -> None:
PHASE5_STATE.write_text(json.dumps(state, indent=2))
async def main() -> int:
log_section(f"🚀 Phase-5 Codegen-Run gestartet")
log(f"Features: {len(FEATURES)}")
state = load_state()
for feature in FEATURES:
if feature.name in state.get("completed_features", []):
continue
state["current_feature"] = feature.name; save_state(state)
try:
success = await run_feature_v2(feature)
if success:
state.setdefault("completed_features", []).append(feature.name)
else:
state.setdefault("attempted_features", []).append(feature.name)
save_state(state)
except Exception as e:
log(f"{feature.name} crashed: {e}", level="ERROR")
state.setdefault("attempted_features", []).append(feature.name); save_state(state)
log_section("Phase-5 Run beendet")
log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}")
# auto-run db:generate + db:migrate for schema-changes (settings table)
import subprocess
log("Running db:generate + db:migrate for schema changes…")
r = subprocess.run(["pnpm", "--filter", "api", "db:generate"], cwd=ROOT, capture_output=True, text=True, timeout=60)
log(f" db:generate rc={r.returncode}: {r.stdout[-300:]}")
r = subprocess.run(["pnpm", "--filter", "api", "db:migrate"], cwd=ROOT, capture_output=True, text=True, timeout=60)
log(f" db:migrate rc={r.returncode}: {r.stdout[-300:]}")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))