feat(dark-mode-toggle): Dark-Mode mit System-Preference + localStorage + toggle [tsc:ok]
This commit is contained in:
parent
38d9258912
commit
596e035a00
@ -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
5
.phase5-state.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"completed_features": [],
|
||||
"current_feature": "dark-mode-toggle",
|
||||
"started_at": "2026-05-23T05:20:11.407108"
|
||||
}
|
||||
@ -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 ✓
|
||||
|
||||
@ -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
54
apps/web/src/lib/theme.ts
Normal 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;
|
||||
}
|
||||
@ -5,6 +5,7 @@ export default {
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}"
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
207
scripts/phase5_features.py
Normal file
207
scripts/phase5_features.py
Normal 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()))
|
||||
Loading…
Reference in New Issue
Block a user