feat: Phase 1 — Projektstruktur, DB-Schema, Auth-System
- Vollständige Verzeichnisstruktur gemäß CLAUDE.md - Express-Server mit Helmet, Sessions, Rate Limiting, SPA-Fallback - SQLite-Schema (Migration v1): 10 Tabellen, updated_at-Triggers, Indizes - Versioniertes Migrations-System (schema_migrations) - Auth-Routen: Login, Logout, /me, Admin-User-CRUD - Frontend App-Shell: SPA-Router, API-Client, Design-System (CSS Tokens) - PWA: Service Worker, Web App Manifest - Setup-Script für ersten Admin-User (node setup.js) - DB-Tests mit node:sqlite built-in: 29/29 bestanden - Docker Compose + Dockerfile + Nginx-Beispielkonfiguration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Modul: API-Client
|
||||
* Zweck: Fetch-Wrapper mit Session-Auth, einheitlicher Fehlerbehandlung und JSON-Parsing
|
||||
* Abhängigkeiten: keine
|
||||
*/
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
/**
|
||||
* Zentraler Fetch-Wrapper.
|
||||
* Setzt Content-Type, handhabt 401-Redirects und parsed JSON-Fehler.
|
||||
*
|
||||
* @param {string} path - API-Pfad ohne /api/v1 (z.B. '/tasks')
|
||||
* @param {RequestInit} options - Fetch-Optionen
|
||||
* @returns {Promise<any>} Geparstes JSON oder wirft einen Fehler
|
||||
*/
|
||||
async function apiFetch(path, options = {}) {
|
||||
const url = `${API_BASE}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Session abgelaufen → zur Login-Seite
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
throw new Error('Sitzung abgelaufen.');
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = data?.error || `HTTP ${response.status}`;
|
||||
throw new ApiError(message, response.status, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strukturierter API-Fehler mit HTTP-Status-Code.
|
||||
*/
|
||||
class ApiError extends Error {
|
||||
constructor(message, status, data = null) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Convenience-Methoden
|
||||
// --------------------------------------------------------
|
||||
|
||||
const api = {
|
||||
get: (path) => apiFetch(path, { method: 'GET' }),
|
||||
|
||||
post: (path, body) => apiFetch(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
put: (path, body) => apiFetch(path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
patch: (path, body) => apiFetch(path, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: (path) => apiFetch(path, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Auth-spezifische Methoden
|
||||
// --------------------------------------------------------
|
||||
|
||||
const auth = {
|
||||
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
me: () => api.get('/auth/me'),
|
||||
getUsers: () => api.get('/auth/users'),
|
||||
createUser: (data) => api.post('/auth/users', data),
|
||||
deleteUser: (id) => api.delete(`/auth/users/${id}`),
|
||||
};
|
||||
|
||||
export { api, auth, ApiError };
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#007AFF" />
|
||||
<meta name="description" content="Oikos — Familienplaner" />
|
||||
<title>Oikos</title>
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/styles/tokens.css" />
|
||||
<link rel="stylesheet" href="/styles/reset.css" />
|
||||
<link rel="stylesheet" href="/styles/layout.css" />
|
||||
<link rel="stylesheet" href="/styles/login.css" />
|
||||
|
||||
<!-- Lucide Icons (CDN) -->
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- App-Shell — wird durch JavaScript gefüllt -->
|
||||
<div id="app" class="app-shell">
|
||||
<!-- Skeleton-Loading während Initialisierung -->
|
||||
<div id="app-loading" class="app-loading" aria-live="polite" aria-label="Lade Oikos…">
|
||||
<div class="app-loading__logo">Oikos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Module (ES-Module, kein Bundler) -->
|
||||
<script type="module" src="/api.js"></script>
|
||||
<script type="module" src="/router.js"></script>
|
||||
|
||||
<!-- Service Worker registrieren -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
||||
console.warn('[SW] Registrierung fehlgeschlagen:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "Oikos Familienplaner",
|
||||
"short_name": "Oikos",
|
||||
"description": "Selbstgehosteter Familienplaner für Kalender, Aufgaben, Einkauf und mehr.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F5F5F7",
|
||||
"theme_color": "#007AFF",
|
||||
"orientation": "portrait-primary",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Budget
|
||||
* Zweck: Seite für das Budget-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Budget</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Calendar
|
||||
* Zweck: Seite für das Calendar-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Calendar</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Contacts
|
||||
* Zweck: Seite für das Contacts-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Contacts</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Dashboard
|
||||
* Zweck: Seite für das Dashboard-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Dashboard</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Modul: Login-Seite
|
||||
* Zweck: Anmeldeformular mit Username/Passwort, Fehlerbehandlung, Session-Start
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { auth } from '/api.js';
|
||||
|
||||
/**
|
||||
* Rendert die Login-Seite in den gegebenen Container.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = `
|
||||
<div class="login-page">
|
||||
<div class="login-card card card--padded">
|
||||
<h1 class="login-card__title">Oikos</h1>
|
||||
<p class="login-card__subtitle">Familienplaner</p>
|
||||
|
||||
<form class="login-form" id="login-form" novalidate>
|
||||
<div class="form-group">
|
||||
<label class="label" for="username">Benutzername</label>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
placeholder="benutzername"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="password">Passwort</label>
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
|
||||
|
||||
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const form = container.querySelector('#login-form');
|
||||
const errorEl = container.querySelector('#login-error');
|
||||
const submitBtn = container.querySelector('#login-btn');
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.hidden = true;
|
||||
|
||||
const username = form.username.value.trim();
|
||||
const password = form.password.value;
|
||||
|
||||
if (!username || !password) {
|
||||
showError(errorEl, 'Bitte alle Felder ausfüllen.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird angemeldet …';
|
||||
|
||||
try {
|
||||
await auth.login(username, password);
|
||||
window.oikos.navigate('/');
|
||||
} catch (err) {
|
||||
showError(errorEl, err.status === 429
|
||||
? 'Zu viele Versuche. Bitte warte kurz.'
|
||||
: 'Ungültige Anmeldedaten.'
|
||||
);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Anmelden';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showError(el, message) {
|
||||
el.textContent = message;
|
||||
el.hidden = false;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Meals
|
||||
* Zweck: Seite für das Meals-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Meals</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Notes
|
||||
* Zweck: Seite für das Notes-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Notes</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Settings
|
||||
* Zweck: Seite für das Settings-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Settings</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Shopping
|
||||
* Zweck: Seite für das Shopping-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Shopping</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modul: Tasks
|
||||
* Zweck: Seite für das Tasks-Modul
|
||||
* Abhängigkeiten: /api.js
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {{ user: object }} context
|
||||
*/
|
||||
export async function render(container, { user }) {
|
||||
container.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page__header">
|
||||
<h1 class="page__title">Tasks</h1>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Kommt bald.</div>
|
||||
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Modul: Client-Side Router
|
||||
* Zweck: SPA-Routing über History API ohne Framework, Auth-Guard, Seiten-Übergänge
|
||||
* Abhängigkeiten: api.js
|
||||
*/
|
||||
|
||||
import { auth } from '/api.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Routen-Definitionen
|
||||
// Jede Route hat: path, page (dynamisch geladen), requiresAuth
|
||||
// --------------------------------------------------------
|
||||
const ROUTES = [
|
||||
{ path: '/login', page: '/pages/login.js', requiresAuth: false },
|
||||
{ path: '/', page: '/pages/dashboard.js', requiresAuth: true },
|
||||
{ path: '/tasks', page: '/pages/tasks.js', requiresAuth: true },
|
||||
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true },
|
||||
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true },
|
||||
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true },
|
||||
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true },
|
||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true },
|
||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true },
|
||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true },
|
||||
];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Globaler App-State
|
||||
// --------------------------------------------------------
|
||||
let currentUser = null;
|
||||
let currentPath = null;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Router
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Navigiert zu einem Pfad und rendert die entsprechende Seite.
|
||||
* @param {string} path
|
||||
* @param {boolean} pushState - false beim initialen Load und popstate
|
||||
*/
|
||||
async function navigate(path, pushState = true) {
|
||||
if (path === currentPath) return;
|
||||
currentPath = path;
|
||||
|
||||
const route = ROUTES.find((r) => r.path === path) ?? ROUTES.find((r) => r.path === '/');
|
||||
|
||||
// Auth-Guard
|
||||
if (route.requiresAuth && !currentUser) {
|
||||
try {
|
||||
const result = await auth.me();
|
||||
currentUser = result.user;
|
||||
} catch {
|
||||
navigateTo('/login', true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!route.requiresAuth && currentUser && path === '/login') {
|
||||
navigateTo('/', true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pushState) {
|
||||
history.pushState({ path }, '', path);
|
||||
}
|
||||
|
||||
await renderPage(route);
|
||||
updateNav(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt und rendert eine Seite dynamisch.
|
||||
* @param {{ path: string, page: string }} route
|
||||
*/
|
||||
async function renderPage(route) {
|
||||
const app = document.getElementById('app');
|
||||
const loading = document.getElementById('app-loading');
|
||||
|
||||
// Loading verstecken
|
||||
if (loading) loading.hidden = true;
|
||||
|
||||
try {
|
||||
const module = await import(route.page + '?v=1');
|
||||
|
||||
if (typeof module.render !== 'function') {
|
||||
throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`);
|
||||
}
|
||||
|
||||
// Seiten-Wrapper erstellen
|
||||
const pageWrapper = document.createElement('div');
|
||||
pageWrapper.className = 'page-transition';
|
||||
pageWrapper.style.animation = 'page-in 0.2s ease forwards';
|
||||
|
||||
await module.render(pageWrapper, { user: currentUser });
|
||||
|
||||
// Nav + Content einmalig aufbauen (beim ersten Render)
|
||||
if (!document.querySelector('.nav-bottom') && currentUser) {
|
||||
renderAppShell(app);
|
||||
}
|
||||
|
||||
const content = document.getElementById('page-content') || app;
|
||||
content.replaceChildren(pageWrapper);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Router] Seiten-Render-Fehler:', err);
|
||||
renderError(app, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* App-Shell mit Navigation einmalig aufbauen (nach erstem Login).
|
||||
*/
|
||||
function renderAppShell(container) {
|
||||
container.innerHTML = `
|
||||
<nav class="nav-sidebar" aria-label="Hauptnavigation">
|
||||
<div class="nav-sidebar__logo">Oikos</div>
|
||||
<div class="nav-sidebar__items" role="list">
|
||||
${navItems().map(navItemHtml).join('')}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="app-content" id="page-content" aria-live="polite">
|
||||
</main>
|
||||
|
||||
<nav class="nav-bottom" aria-label="Navigation">
|
||||
<div class="nav-bottom__items" role="list">
|
||||
${navItems().slice(0, 5).map(navItemHtml).join('')}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="toast-container" id="toast-container" aria-live="assertive"></div>
|
||||
`;
|
||||
|
||||
// Klick-Handler für alle Nav-Links
|
||||
container.querySelectorAll('[data-route]').forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
navigate(el.dataset.route);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function navItems() {
|
||||
return [
|
||||
{ path: '/', label: 'Übersicht', icon: 'layout-dashboard' },
|
||||
{ path: '/tasks', label: 'Aufgaben', icon: 'check-square' },
|
||||
{ path: '/calendar', label: 'Kalender', icon: 'calendar' },
|
||||
{ path: '/meals', label: 'Essen', icon: 'utensils' },
|
||||
{ path: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
|
||||
{ path: '/notes', label: 'Pinnwand', icon: 'sticky-note' },
|
||||
{ path: '/contacts', label: 'Kontakte', icon: 'book-user' },
|
||||
{ path: '/budget', label: 'Budget', icon: 'wallet' },
|
||||
{ path: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
];
|
||||
}
|
||||
|
||||
function navItemHtml({ path, label, icon }) {
|
||||
return `
|
||||
<a href="${path}" data-route="${path}" class="nav-item" role="listitem" aria-label="${label}">
|
||||
<i data-lucide="${icon}" class="nav-item__icon" aria-hidden="true"></i>
|
||||
<span class="nav-item__label">${label}</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiven Nav-Link hervorheben.
|
||||
*/
|
||||
function updateNav(path) {
|
||||
document.querySelectorAll('[data-route]').forEach((el) => {
|
||||
el.removeAttribute('aria-current');
|
||||
if (el.dataset.route === path) {
|
||||
el.setAttribute('aria-current', 'page');
|
||||
}
|
||||
});
|
||||
|
||||
// Lucide Icons neu rendern (nach DOM-Update)
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
function renderError(container, err) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Etwas ist schiefgelaufen.</div>
|
||||
<div class="empty-state__description">${err.message}</div>
|
||||
<button class="btn btn--primary" onclick="location.reload()">Neu laden</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Toast-Benachrichtigungen (global)
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zeigt eine Toast-Benachrichtigung an.
|
||||
* @param {string} message
|
||||
* @param {'default'|'success'|'danger'|'warning'} type
|
||||
* @param {number} duration - ms
|
||||
*/
|
||||
function showToast(message, type = 'default', duration = 3000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`;
|
||||
toast.textContent = message;
|
||||
toast.setAttribute('role', 'alert');
|
||||
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Event-Listener
|
||||
// --------------------------------------------------------
|
||||
|
||||
// Browser zurück/vor
|
||||
window.addEventListener('popstate', (e) => {
|
||||
navigate(e.state?.path || location.pathname, false);
|
||||
});
|
||||
|
||||
// Session abgelaufen
|
||||
window.addEventListener('auth:expired', () => {
|
||||
currentUser = null;
|
||||
navigate('/login');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Initialisierung
|
||||
// --------------------------------------------------------
|
||||
navigate(location.pathname, false);
|
||||
|
||||
// Globale Exporte
|
||||
window.oikos = { navigate, showToast };
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Modul: Layout
|
||||
* Zweck: App-Shell-Layout, Navigation (Bottom Mobile / Sidebar Desktop), Responsive Grid
|
||||
* Abhängigkeiten: tokens.css, reset.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* App-Shell
|
||||
* -------------------------------------------------------- */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Loading-Screen
|
||||
* -------------------------------------------------------- */
|
||||
.app-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.app-loading__logo {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Layout: Mobile (Standard, < 1024px)
|
||||
* -------------------------------------------------------- */
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding-bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Bottom Navigation (Mobil + Tablet)
|
||||
* -------------------------------------------------------- */
|
||||
.nav-bottom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom));
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
background-color: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: var(--z-nav);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.nav-bottom__items {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-1);
|
||||
color: var(--color-text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
min-height: unset; /* Reset des Touch-Target-Minimums für Nav-Items */
|
||||
}
|
||||
|
||||
.nav-item[aria-current="page"] {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-item__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.nav-item__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Sidebar Navigation (Desktop, > 1024px)
|
||||
* -------------------------------------------------------- */
|
||||
@media (min-width: 1024px) {
|
||||
.app-shell {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
padding-bottom: 0;
|
||||
margin-left: var(--sidebar-width);
|
||||
}
|
||||
|
||||
.nav-bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: var(--z-nav);
|
||||
padding: var(--space-6) 0;
|
||||
}
|
||||
|
||||
.nav-sidebar__logo {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent);
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.nav-sidebar__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-sidebar .nav-item {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3) var(--space-3);
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.nav-sidebar .nav-item[aria-current="page"] {
|
||||
background-color: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-sidebar .nav-item:hover:not([aria-current="page"]) {
|
||||
background-color: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.nav-sidebar .nav-item__label {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Container
|
||||
* -------------------------------------------------------- */
|
||||
.page {
|
||||
padding: var(--space-4);
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-6);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page__title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.page {
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Cards
|
||||
* -------------------------------------------------------- */
|
||||
.card {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card--padded {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Buttons
|
||||
* -------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
min-height: 44px;
|
||||
transition: opacity var(--transition-fast), background-color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background-color: var(--color-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn--primary:hover {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background-color: transparent;
|
||||
color: var(--color-accent);
|
||||
border: 1.5px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background-color: var(--color-danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn--ghost:hover {
|
||||
background-color: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--icon {
|
||||
padding: var(--space-2);
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* FAB (Floating Action Button) */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-4));
|
||||
right: var(--space-4);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-accent);
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: calc(var(--z-nav) - 1);
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.fab {
|
||||
bottom: var(--space-8);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Form-Elemente
|
||||
* -------------------------------------------------------- */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--text-base);
|
||||
transition: border-color var(--transition-fast);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Skeleton-Loading
|
||||
* -------------------------------------------------------- */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-border) 25%,
|
||||
var(--color-surface-2) 50%,
|
||||
var(--color-border) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustände (Empty States)
|
||||
* -------------------------------------------------------- */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state__icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--color-text-disabled);
|
||||
}
|
||||
|
||||
.empty-state__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state__description {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Responsive Grid
|
||||
* -------------------------------------------------------- */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid--2 { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Toast-Benachrichtigungen
|
||||
* -------------------------------------------------------- */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-4));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
z-index: var(--z-toast);
|
||||
pointer-events: none;
|
||||
width: min(calc(100% - var(--space-8)), 400px);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.toast-container {
|
||||
bottom: var(--space-6);
|
||||
left: calc(var(--sidebar-width) + 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
background-color: var(--color-text-primary);
|
||||
color: var(--color-bg);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
pointer-events: auto;
|
||||
animation: toast-in 0.2s ease forwards;
|
||||
}
|
||||
|
||||
.toast--success { background-color: var(--color-success); color: #fff; }
|
||||
.toast--danger { background-color: var(--color-danger); color: #fff; }
|
||||
.toast--warning { background-color: var(--color-warning); color: #fff; }
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Modul: Login-Seite
|
||||
* Zweck: Styles für die Anmeldeseite
|
||||
* Abhängigkeiten: tokens.css, reset.css, layout.css
|
||||
*/
|
||||
|
||||
.login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
padding: var(--space-4);
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.login-card__title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-accent);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.login-card__subtitle {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.login-form__submit {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background-color: var(--color-danger-light);
|
||||
color: var(--color-danger);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Modul: CSS Reset
|
||||
* Zweck: Browser-Defaults normalisieren, Box-Sizing, Touch-Verhalten
|
||||
* Abhängigkeiten: tokens.css
|
||||
*/
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
img, svg, video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
/* Touch-Targets: Mindestgröße 44px */
|
||||
button, [role="button"], input[type="checkbox"], input[type="radio"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Fokus-Styles (Accessibility) */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
|
||||
/* Versteckte, aber zugängliche Elemente */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Modul: Design Tokens
|
||||
* Zweck: CSS Custom Properties für das gesamte Design-System
|
||||
* Abhängigkeiten: keine
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --------------------------------------------------------
|
||||
* Farben — Neutrals
|
||||
* -------------------------------------------------------- */
|
||||
--color-bg: #F5F5F7;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-surface-2: #F0F0F5;
|
||||
--color-border: #E5E5EA;
|
||||
--color-text-primary: #1C1C1E;
|
||||
--color-text-secondary: #8E8E93;
|
||||
--color-text-disabled: #C7C7CC;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Farben — Akzent (konfigurierbar)
|
||||
* -------------------------------------------------------- */
|
||||
--color-accent: #007AFF;
|
||||
--color-accent-hover: #0056CC;
|
||||
--color-accent-light: #E3F2FF;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Farben — Semantisch
|
||||
* -------------------------------------------------------- */
|
||||
--color-success: #34C759;
|
||||
--color-success-light: #E3F9EB;
|
||||
--color-warning: #FF9500;
|
||||
--color-warning-light: #FFF3E0;
|
||||
--color-danger: #FF3B30;
|
||||
--color-danger-light: #FFE5E3;
|
||||
--color-info: #5AC8FA;
|
||||
--color-info-light: #E5F7FF;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Farben — Prioritäten
|
||||
* -------------------------------------------------------- */
|
||||
--color-priority-low: #8E8E93;
|
||||
--color-priority-medium: #FF9500;
|
||||
--color-priority-high: #FF6B35;
|
||||
--color-priority-urgent: #FF3B30;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Schatten
|
||||
* -------------------------------------------------------- */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.10);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Border-Radien
|
||||
* -------------------------------------------------------- */
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Typografie
|
||||
* -------------------------------------------------------- */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
|
||||
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.8125rem; /* 13px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-md: 1.0625rem; /* 17px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
|
||||
--line-height-tight: 1.2;
|
||||
--line-height-base: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Abstände
|
||||
* -------------------------------------------------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Layout
|
||||
* -------------------------------------------------------- */
|
||||
--nav-height-mobile: 64px;
|
||||
--sidebar-width: 240px;
|
||||
--content-max-width: 1200px;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Übergänge
|
||||
* -------------------------------------------------------- */
|
||||
--transition-fast: 0.1s ease;
|
||||
--transition-base: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Z-Indizes
|
||||
* -------------------------------------------------------- */
|
||||
--z-base: 0;
|
||||
--z-card: 1;
|
||||
--z-nav: 100;
|
||||
--z-modal: 200;
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Dark Mode
|
||||
* -------------------------------------------------------- */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #1C1C1E;
|
||||
--color-surface: #2C2C2E;
|
||||
--color-surface-2: #3A3A3C;
|
||||
--color-border: #3A3A3C;
|
||||
--color-text-primary: #F5F5F7;
|
||||
--color-text-secondary: #8E8E93;
|
||||
--color-text-disabled: #48484A;
|
||||
|
||||
--color-accent-light: #1A3A5C;
|
||||
--color-success-light: #1A3A26;
|
||||
--color-warning-light: #3A2800;
|
||||
--color-danger-light: #3A1A1A;
|
||||
--color-info-light: #1A3A4A;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Modul: Service Worker
|
||||
* Zweck: Offline-Fähigkeit (App-Shell-Caching), Hintergrund-Sync
|
||||
* Abhängigkeiten: keine
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'oikos-v1';
|
||||
|
||||
// App-Shell-Ressourcen, die offline verfügbar sein sollen
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/api.js',
|
||||
'/router.js',
|
||||
'/styles/tokens.css',
|
||||
'/styles/reset.css',
|
||||
'/styles/layout.css',
|
||||
'/styles/login.css',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Install: App-Shell cachen
|
||||
// --------------------------------------------------------
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Activate: Alte Caches löschen
|
||||
// --------------------------------------------------------
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Fetch: Netzwerk-First für API, Cache-First für App-Shell
|
||||
// --------------------------------------------------------
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// API-Requests: immer Netzwerk (kein Caching von Nutzerdaten)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return; // Browser übernimmt
|
||||
}
|
||||
|
||||
// App-Shell: Cache-First, Fallback Netzwerk
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
|
||||
return fetch(request).then((response) => {
|
||||
if (response.ok && response.type === 'basic') {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => {
|
||||
// Offline-Fallback für Seiten-Navigation
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/index.html');
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user