feat: merge UX polish v0.2.0 (14 tasks, 4 layers)
This commit is contained in:
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
- Directional slide-x page transitions (forward = right, backward = left) with race condition guard
|
||||
- PWA install prompt delayed until 2 user interactions; dismiss window reduced from 30 to 7 days; interaction counter resets on dismiss
|
||||
- Unified card padding to 16px (`--space-4`) across tasks, contacts, budget, and meals modules
|
||||
|
||||
### Added
|
||||
- Staggered fade-in animation for list items on page load across all modules (tasks, shopping, meals, contacts, budget, notes, calendar agenda)
|
||||
- Unified empty states using shared `.empty-state` class across all modules (replaces per-module CSS)
|
||||
- `stagger()` and `vibrate()` UX utilities in `public/utils/ux.js` with full test coverage
|
||||
- Proportional opacity on swipe-reveal action areas in tasks (already implemented, confirmed)
|
||||
- FAB colors tied to per-module accent tokens via CSS custom properties
|
||||
- `scrollIntoView` for focused inputs when virtual keyboard opens in modals (300ms delay)
|
||||
- Consistent vibration feedback via `vibrate()` utility across tasks, shopping, contacts, budget, and notes
|
||||
- Bottom sheet modal on mobile (< 768px) with drag handle, slide-in animation, and swipe-to-close
|
||||
- Enter-key navigation between form fields in modals; Enter on last field triggers submit
|
||||
- Blur-triggered inline validation for required fields with error/success border states
|
||||
- `wireBlurValidation()`, `btnSuccess()`, and `btnError()` exported from `modal.js`
|
||||
- Submit button checkmark-success (700ms) and shake-error feedback animations
|
||||
|
||||
## [0.1.0] - 2026-03-29
|
||||
|
||||
Initial release of Oikos — a self-hosted family planner for 2–6 person households. Runs as a Docker container behind Nginx with SSL, no cloud dependency.
|
||||
|
||||
+11
-6
@@ -273,12 +273,17 @@ Benutzerverwaltung und App-Konfiguration. Nur für eingeloggte Nutzer.
|
||||
- Caption: 13px, `var(--color-text-secondary)`
|
||||
|
||||
### Komponenten
|
||||
- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)`
|
||||
- **Buttons:** Primär = Accent + weiß. Sekundär = Outline. Min-Höhe 44px
|
||||
- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px
|
||||
- **Navigation:** Bottom Tab Bar mobil (Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar Desktop
|
||||
- **Transitions:** `all 0.2s ease`. Seiten: Slide-Animation
|
||||
- **Empty States:** Illustration + CTA in jeder Liste
|
||||
- **Cards:** `var(--color-surface)`, `var(--radius-md)`, `var(--shadow-sm)`. Einheitliches Padding `var(--space-4)` (16px) in allen Modulen.
|
||||
- **Buttons:** Primär = Accent + weiß. Sekundär = Outline. Min-Höhe 44px. Submit-Buttons zeigen Erfolg (Checkmark, 700ms grün via `.btn--success`) und Fehler (Shake via `.btn--shaking`).
|
||||
- **Inputs:** `var(--radius-sm)`, 1.5px border, padding 12px 16px. `[required]`-Felder erhalten bei Blur Validierungsstatus (`.form-field--error` / `.form-field--valid`). Enter navigiert zum nächsten Feld; Enter im letzten Feld löst Submit aus.
|
||||
- **FAB (Floating Action Button):** Farbe folgt dem Modul-Akzent-Token (`--module-accent`) — jedes Modul definiert seine eigene Akzentfarbe.
|
||||
- **Navigation:** Bottom Tab Bar mobil (Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar Desktop.
|
||||
- **Transitions:** Direktionale Slide-X-Animation bei Seitenwechsel (vorwärts = von rechts, rückwärts = von links, 200ms). Respektiert `prefers-reduced-motion`.
|
||||
- **Empty States:** Einheitliche `.empty-state`-Klasse in allen Modulen (Icon + Titel + Beschreibung, zentriert). Kompakte Variante `.empty-state--compact` für Mahlzeiten-Slots.
|
||||
- **Modals:** Auf Desktop zentriertes Panel. Auf Mobile (< 768px) Bottom Sheet — fährt von unten ein, Sheet-Handle sichtbar, Swipe-to-Close (> 80px nach unten). `focusin` scrollt Inputs bei virtueller Tastatur in den sichtbaren Bereich.
|
||||
- **Listen-Animation:** Staggered Fade-In beim Laden (`stagger()` aus `public/utils/ux.js`) — max. 5 Elemente gestaffelt (30ms Abstand), Rest sofort.
|
||||
- **Vibration:** `vibrate()` aus `public/utils/ux.js` — kurze Impulse bei leichten Aktionen (10–40ms), Muster `[30, 50, 30]` bei destructiven Aktionen (Löschen). Respektiert `prefers-reduced-motion`.
|
||||
- **PWA Install Prompt:** Erscheint erst nach 2 Nutzer-Interaktionen. Dismiss-Fenster 7 Tage; nach Dismiss wird der Interaktionszähler zurückgesetzt.
|
||||
|
||||
### Breakpoints
|
||||
- Mobil: < 768px (1 Spalte, Bottom Nav)
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "1.0.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Selbstgehosteter Familienplaner — Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
@@ -14,7 +14,8 @@
|
||||
"test:meals": "node --experimental-sqlite test-meals.js",
|
||||
"test:calendar": "node --experimental-sqlite test-calendar.js",
|
||||
"test:ncb": "node --experimental-sqlite test-notes-contacts-budget.js",
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js"
|
||||
"test:ux-utils": "node test-ux-utils.js",
|
||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
|
||||
+181
-27
@@ -2,6 +2,7 @@
|
||||
* Modul: Shared Modal-System
|
||||
* Zweck: Einheitliches Modal mit Focus-Trap, Escape-Handler, Overlay-Click,
|
||||
* Focus-Restore, Scroll-Lock und aria-modal.
|
||||
* Auf Mobile: Bottom Sheet mit Swipe-to-Close und Slide-out-Animation.
|
||||
* Abhängigkeiten: CSS-Klassen aus layout.css (.modal-overlay, .modal-panel, etc.)
|
||||
*
|
||||
* API:
|
||||
@@ -31,22 +32,62 @@ const FOCUSABLE = [
|
||||
|
||||
function trapFocus(container) {
|
||||
focusTrapHandler = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusable = container.querySelectorAll(FOCUSABLE);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
// Tab-Trap: Fokus innerhalb des Modals halten
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = container.querySelectorAll(FOCUSABLE);
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter in einzeiligen Inputs/Selects → zum nächsten Feld springen
|
||||
if (e.key === 'Enter') {
|
||||
const active = document.activeElement;
|
||||
const isInput = active.tagName === 'INPUT' && active.type !== 'submit' && active.type !== 'button';
|
||||
const isSelect = active.tagName === 'SELECT';
|
||||
|
||||
if (isInput || isSelect) {
|
||||
const focusable = Array.from(container.querySelectorAll(FOCUSABLE));
|
||||
const idx = focusable.indexOf(active);
|
||||
const next = focusable[idx + 1];
|
||||
|
||||
if (next && next.tagName !== 'BUTTON') {
|
||||
e.preventDefault();
|
||||
next.focus();
|
||||
}
|
||||
// Beim letzten Feld oder wenn Next ein Button ist: Submit auslösen
|
||||
if (!next || next.tagName === 'BUTTON') {
|
||||
const submitBtn = container.querySelector('button[type="submit"], .btn--primary');
|
||||
if (submitBtn && !submitBtn.disabled) {
|
||||
e.preventDefault();
|
||||
submitBtn.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
container.addEventListener('keydown', focusTrapHandler);
|
||||
|
||||
// Virtual Keyboard: Focused Input in sichtbaren Bereich scrollen
|
||||
function onInputFocus(e) {
|
||||
const tag = e.target.tagName;
|
||||
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return;
|
||||
setTimeout(() => {
|
||||
e.target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 300);
|
||||
}
|
||||
container.addEventListener('focusin', onInputFocus);
|
||||
container._onInputFocus = onInputFocus;
|
||||
|
||||
// Focus first focusable element
|
||||
const first = container.querySelector(FOCUSABLE);
|
||||
if (first) {
|
||||
@@ -62,6 +103,61 @@ function onEscape(e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Swipe-to-Close (Mobile)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function _wireSheetSwipe(panel) {
|
||||
let startY = 0;
|
||||
let dragging = false;
|
||||
|
||||
panel.addEventListener('touchstart', (e) => {
|
||||
startY = e.touches[0].clientY;
|
||||
dragging = true;
|
||||
}, { passive: true });
|
||||
|
||||
panel.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
if (dy < 0) return; // Kein Swipe nach oben
|
||||
panel.style.transform = `translateY(${dy * 0.6}px)`;
|
||||
}, { passive: true });
|
||||
|
||||
panel.addEventListener('touchend', (e) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
const dy = e.changedTouches[0].clientY - startY;
|
||||
panel.style.transform = '';
|
||||
if (dy > 80) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// _doClose — gemeinsame Cleanup-Logik
|
||||
// --------------------------------------------------------
|
||||
|
||||
function _doClose() {
|
||||
if (!activeOverlay) return;
|
||||
activeOverlay.remove();
|
||||
activeOverlay = null;
|
||||
|
||||
// Scroll-Lock aufheben
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Focus-Restore
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
}
|
||||
|
||||
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen
|
||||
if (window.oikos?.restoreThemeColor) {
|
||||
window.oikos.restoreThemeColor();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// openModal
|
||||
// --------------------------------------------------------
|
||||
@@ -115,6 +211,11 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
trapFocus(panel);
|
||||
|
||||
// Swipe-to-Close auf Mobile
|
||||
if (window.innerWidth < 768) {
|
||||
_wireSheetSwipe(panel);
|
||||
}
|
||||
|
||||
// Overlay-Click schließt Modal
|
||||
activeOverlay.addEventListener('click', (e) => {
|
||||
if (e.target === activeOverlay) closeModal();
|
||||
@@ -145,27 +246,80 @@ export function closeModal() {
|
||||
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
|
||||
// Focus-Trap-Handler entfernen
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
|
||||
// Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen
|
||||
if (focusTrapHandler) {
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
if (panel) panel.removeEventListener('keydown', focusTrapHandler);
|
||||
focusTrapHandler = null;
|
||||
}
|
||||
|
||||
activeOverlay.remove();
|
||||
activeOverlay = null;
|
||||
|
||||
// Scroll-Lock aufheben
|
||||
document.body.style.overflow = '';
|
||||
|
||||
// Focus-Restore
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
||||
previouslyFocused.focus();
|
||||
previouslyFocused = null;
|
||||
if (panel?._onInputFocus) {
|
||||
panel.removeEventListener('focusin', panel._onInputFocus);
|
||||
}
|
||||
|
||||
// Standalone: Statusbar-Farbe zur aktuellen Route wiederherstellen
|
||||
if (window.oikos?.restoreThemeColor) {
|
||||
window.oikos.restoreThemeColor();
|
||||
// Sheet-Out-Animation auf Mobile, danach _doClose
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile && panel) {
|
||||
panel.classList.add('modal-panel--closing');
|
||||
panel.addEventListener('animationend', () => {
|
||||
_doClose();
|
||||
}, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
_doClose();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Inline Blur-Validierung
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Aktiviert Blur-Validierung für alle required-Inputs in einem Container.
|
||||
* @param {HTMLElement} formContainer
|
||||
*/
|
||||
export function wireBlurValidation(formContainer) {
|
||||
formContainer.querySelectorAll('input[required], select[required], textarea[required]').forEach((input) => {
|
||||
input.addEventListener('blur', () => {
|
||||
const group = input.closest('.form-field') ?? input.parentElement;
|
||||
const hasValue = input.value.trim().length > 0;
|
||||
group?.classList.toggle('form-field--error', !hasValue);
|
||||
group?.classList.toggle('form-field--valid', hasValue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Submit-Feedback (Checkmark + Shake)
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms).
|
||||
* @param {HTMLButtonElement} btn
|
||||
* @param {string} [originalLabel]
|
||||
*/
|
||||
export function btnSuccess(btn, originalLabel) {
|
||||
const label = originalLabel ?? btn.textContent;
|
||||
btn.classList.add('btn--success');
|
||||
btn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
`;
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('btn--success');
|
||||
btn.textContent = label;
|
||||
}, 700);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Fehler-Feedback auf einem Button (Shake-Animation).
|
||||
* @param {HTMLButtonElement} btn
|
||||
*/
|
||||
export function btnError(btn) {
|
||||
btn.classList.remove('btn--shaking');
|
||||
void btn.offsetWidth; // Reflow für Animation-Restart
|
||||
btn.classList.add('btn--shaking');
|
||||
btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true });
|
||||
}
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
* - Chrome/Android: Fängt beforeinstallprompt ab, zeigt Install-Banner
|
||||
* - iOS (Safari): Zeigt Anleitung "Zum Home-Bildschirm"
|
||||
* - Standalone-Modus: Zeigt nichts an
|
||||
* - Dismiss: 30 Tage via localStorage gespeichert
|
||||
* - Dismiss: 7 Tage via localStorage gespeichert
|
||||
* - Timing: Banner erst nach 2 Nutzer-Interaktionen anzeigen
|
||||
*/
|
||||
|
||||
const DISMISS_KEY = 'oikos-install-dismissed';
|
||||
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 Tage
|
||||
const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
|
||||
|
||||
const INTERACTION_KEY = 'oikos-install-interactions';
|
||||
const INTERACTION_THRESHOLD = 2;
|
||||
|
||||
class OikosInstallPrompt extends HTMLElement {
|
||||
constructor() {
|
||||
@@ -35,6 +39,13 @@ class OikosInstallPrompt extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Noch nicht genug Interaktionen
|
||||
const interactions = Number(localStorage.getItem(INTERACTION_KEY) || '0');
|
||||
if (interactions < INTERACTION_THRESHOLD) {
|
||||
this._waitForInteractions();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._isIOS()) {
|
||||
this._showIOSPrompt();
|
||||
} else {
|
||||
@@ -44,6 +55,25 @@ class OikosInstallPrompt extends HTMLElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('beforeinstallprompt', this._onBeforeInstall);
|
||||
if (this._offInteraction) this._offInteraction();
|
||||
}
|
||||
|
||||
_waitForInteractions() {
|
||||
const onInteraction = () => {
|
||||
const count = Number(localStorage.getItem(INTERACTION_KEY) || '0') + 1;
|
||||
localStorage.setItem(INTERACTION_KEY, String(count));
|
||||
|
||||
if (count >= INTERACTION_THRESHOLD) {
|
||||
document.removeEventListener('click', onInteraction);
|
||||
if (this._isIOS()) {
|
||||
this._showIOSPrompt();
|
||||
} else {
|
||||
this._listenForInstallPrompt();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', onInteraction);
|
||||
this._offInteraction = () => document.removeEventListener('click', onInteraction);
|
||||
}
|
||||
|
||||
/** iOS Safari erkennen (kein beforeinstallprompt-Support) */
|
||||
@@ -307,9 +337,10 @@ class OikosInstallPrompt extends HTMLElement {
|
||||
this._deferredPrompt = null;
|
||||
}
|
||||
|
||||
/** Dismiss: 30 Tage merken, Banner entfernen */
|
||||
/** Dismiss: 7 Tage merken, Interaction-Counter zurücksetzen, Banner entfernen */
|
||||
_dismiss() {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
localStorage.removeItem(INTERACTION_KEY);
|
||||
this._remove();
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -7,6 +7,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -202,6 +203,7 @@ function renderBody() {
|
||||
`;
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
stagger(_container.querySelector('#budget-list')?.querySelectorAll('.budget-entry') ?? []);
|
||||
|
||||
_container.querySelector('#budget-list')?.addEventListener('click', async (e) => {
|
||||
const delBtn = e.target.closest('[data-action="delete"]');
|
||||
@@ -239,10 +241,13 @@ function renderCategoryBars(byCategory) {
|
||||
|
||||
function renderEntries() {
|
||||
if (!state.entries.length) {
|
||||
return `<div class="budget-empty">
|
||||
<i data-lucide="receipt" style="width:48px;height:48px;color:var(--color-text-disabled);margin-bottom:var(--space-3);" aria-hidden="true"></i>
|
||||
<div style="font-size:var(--text-base);font-weight:600;">Keine Einträge</div>
|
||||
<div style="font-size:var(--text-sm);margin-top:var(--space-1);">Noch keine Transaktionen für diesen Monat.</div>
|
||||
return `<div class="empty-state">
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<div class="empty-state__title">Keine Einträge diesen Monat</div>
|
||||
<div class="empty-state__description">Budget-Einträge über den + Button hinzufügen.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -419,6 +424,7 @@ async function deleteEntry(id) {
|
||||
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
|
||||
state.summary = sumRes.data;
|
||||
renderBody();
|
||||
vibrate([30, 50, 30]);
|
||||
window.oikos?.showToast('Eintrag gelöscht', 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { api } from '/api.js';
|
||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -597,6 +598,8 @@ function renderAgendaView(container) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
stagger(container.querySelectorAll('.agenda-event'));
|
||||
|
||||
container.querySelector('#agenda-view').addEventListener('click', (e) => {
|
||||
const evEl = e.target.closest('.agenda-event');
|
||||
if (evEl) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -133,9 +134,15 @@ function renderList() {
|
||||
|
||||
if (!contacts.length) {
|
||||
container.innerHTML = `
|
||||
<div class="contacts-empty">
|
||||
<i data-lucide="users" style="width:48px;height:48px;color:var(--color-text-disabled);margin-bottom:var(--space-3);" aria-hidden="true"></i>
|
||||
<div style="font-size:var(--text-base);font-weight:600;">Keine Kontakte gefunden</div>
|
||||
<div class="empty-state">
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<div class="empty-state__title">Noch keine Kontakte</div>
|
||||
<div class="empty-state__description">Neue Kontakte über den + Button hinzufügen.</div>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -159,6 +166,7 @@ function renderList() {
|
||||
`).join('');
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
stagger(container.querySelectorAll('.contact-item'));
|
||||
|
||||
// Event-Delegation
|
||||
container.addEventListener('click', async (e) => {
|
||||
@@ -306,6 +314,7 @@ async function deleteContact(id) {
|
||||
await api.delete(`/contacts/${id}`);
|
||||
state.contacts = state.contacts.filter((c) => c.id !== id);
|
||||
renderList();
|
||||
vibrate([30, 50, 30]);
|
||||
window.oikos?.showToast('Kontakt gelöscht', 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js';
|
||||
import { stagger } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -162,6 +163,7 @@ function renderWeekGrid() {
|
||||
}).join('');
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
stagger(grid.querySelectorAll('.meal-card'));
|
||||
wireGrid(grid);
|
||||
}
|
||||
|
||||
@@ -170,8 +172,11 @@ function renderSlot(date, type, mealsForDay) {
|
||||
|
||||
if (!meal) {
|
||||
return `
|
||||
<div class="meal-slot" data-date="${date}" data-type="${type.key}">
|
||||
<div class="meal-slot meal-slot--empty" data-date="${date}" data-type="${type.key}">
|
||||
<div class="meal-slot__type-label">${type.label}</div>
|
||||
<div class="empty-state empty-state--compact">
|
||||
<div class="empty-state__description">Kein Essen geplant</div>
|
||||
</div>
|
||||
<button
|
||||
class="meal-slot__add-btn"
|
||||
data-action="add-meal"
|
||||
|
||||
+13
-4
@@ -6,6 +6,7 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -87,10 +88,16 @@ function renderGrid() {
|
||||
|
||||
if (!state.notes.length) {
|
||||
grid.innerHTML = `
|
||||
<div class="notes-empty">
|
||||
<i data-lucide="sticky-note" class="notes-empty__icon" aria-hidden="true"></i>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-bottom:var(--space-2);">Noch keine Notizen</div>
|
||||
<div style="font-size:var(--text-sm);">Klicke auf „Neue Notiz" um loszulegen.</div>
|
||||
<div class="empty-state" style="column-span:all;">
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<div class="empty-state__title">Noch keine Notizen</div>
|
||||
<div class="empty-state__description">Neue Notiz über den + Button erstellen.</div>
|
||||
</div>
|
||||
`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -99,6 +106,7 @@ function renderGrid() {
|
||||
|
||||
grid.innerHTML = state.notes.map((n) => renderNoteCard(n)).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
stagger(grid.querySelectorAll('.note-card'));
|
||||
|
||||
grid.addEventListener('click', async (e) => {
|
||||
// Pin
|
||||
@@ -451,6 +459,7 @@ async function deleteNote(id) {
|
||||
await api.delete(`/notes/${id}`);
|
||||
state.notes = state.notes.filter((n) => n.id !== id);
|
||||
renderGrid();
|
||||
vibrate([30, 50, 30]);
|
||||
window.oikos?.showToast('Notiz gelöscht', 'success');
|
||||
} catch (err) {
|
||||
window.oikos?.showToast(err.data?.error ?? 'Fehler', 'error');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -151,6 +152,7 @@ function renderListContent(container) {
|
||||
`;
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
stagger(content.querySelectorAll('.shopping-item'));
|
||||
wireAutocomplete(container);
|
||||
wireQuickAdd(container);
|
||||
}
|
||||
@@ -158,10 +160,13 @@ function renderListContent(container) {
|
||||
function renderItems() {
|
||||
if (!state.items.length) {
|
||||
return `
|
||||
<div class="shopping-empty">
|
||||
<i data-lucide="check-circle" class="shopping-empty__icon" aria-hidden="true"></i>
|
||||
<div class="shopping-empty__title">Liste ist leer</div>
|
||||
<div class="shopping-empty__desc">Füge Artikel mit dem Eingabefeld oben hinzu.</div>
|
||||
<div class="empty-state">
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
|
||||
</svg>
|
||||
<div class="empty-state__title">Die Liste ist leer</div>
|
||||
<div class="empty-state__description">Artikel über das Eingabefeld oben hinzufügen.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -311,6 +316,7 @@ function updateItemsList(container) {
|
||||
if (listEl) {
|
||||
listEl.innerHTML = renderItems();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
stagger(listEl.querySelectorAll('.shopping-item'));
|
||||
}
|
||||
// clear-checked Button aktualisieren
|
||||
const checkedCount = state.items.filter((i) => i.is_checked).length;
|
||||
@@ -435,6 +441,7 @@ function wireListContentEvents(container) {
|
||||
|
||||
try {
|
||||
await api.patch(`/shopping/items/${id}`, { is_checked: newVal });
|
||||
vibrate(10);
|
||||
} catch (err) {
|
||||
// Zurückrollen
|
||||
if (item) item.is_checked = checked;
|
||||
|
||||
+34
-13
@@ -6,7 +6,8 @@
|
||||
|
||||
import { api } from '/api.js';
|
||||
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js';
|
||||
import { openModal as openSharedModal, closeModal } from '/components/modal.js';
|
||||
import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSuccess, btnError } from '/components/modal.js';
|
||||
import { stagger, vibrate } from '/utils/ux.js';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Konstanten
|
||||
@@ -194,10 +195,13 @@ function renderTaskCard(task, opts = {}) {
|
||||
|
||||
function renderTaskGroups(tasks, groupMode) {
|
||||
if (!tasks.length) {
|
||||
return `<div class="tasks-empty">
|
||||
<i data-lucide="check-circle-2" class="tasks-empty__icon" aria-hidden="true"></i>
|
||||
<div class="tasks-empty__title">Keine Aufgaben</div>
|
||||
<div class="tasks-empty__desc">Erstelle eine neue Aufgabe mit dem + Button.</div>
|
||||
return `<div class="empty-state">
|
||||
<svg class="empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<div class="empty-state__title">Keine Aufgaben — alles erledigt?</div>
|
||||
<div class="empty-state__description">Neue Aufgaben über den + Button erstellen.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -236,10 +240,19 @@ function renderModalContent({ task = null, users = [] } = {}) {
|
||||
<input type="hidden" id="task-id" value="${task?.id ?? ''}">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="label" for="task-title">Titel *</label>
|
||||
<input class="input" type="text" id="task-title" name="title"
|
||||
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
|
||||
required autocomplete="off">
|
||||
<div class="form-field">
|
||||
<label class="label" for="task-title">Titel *</label>
|
||||
<input class="input" type="text" id="task-title" name="title"
|
||||
value="${task?.title ?? ''}" placeholder="Was muss erledigt werden?"
|
||||
required autocomplete="off">
|
||||
<div class="form-field__error">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/>
|
||||
</svg>
|
||||
Dieses Feld ist erforderlich.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -369,6 +382,9 @@ function openTaskModal({ task = null, users = [] } = {}, container) {
|
||||
// RRULE-Events binden
|
||||
bindRRuleEvents(document, 'task');
|
||||
|
||||
// Blur-Validierung für required-Felder aktivieren
|
||||
wireBlurValidation(panel);
|
||||
|
||||
// Form-Events
|
||||
panel.querySelector('#task-form')
|
||||
?.addEventListener('submit', (e) => handleFormSubmit(e, container));
|
||||
@@ -394,6 +410,8 @@ async function handleFormSubmit(e, container) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gespeichert…';
|
||||
|
||||
const originalLabel = taskId ? 'Speichern' : 'Erstellen';
|
||||
|
||||
const rrule = getRRuleValues(document, 'task');
|
||||
const body = {
|
||||
title: form.title.value.trim(),
|
||||
@@ -416,13 +434,15 @@ async function handleFormSubmit(e, container) {
|
||||
await api.post('/tasks', body);
|
||||
window.oikos.showToast('Aufgabe erstellt.', 'success');
|
||||
}
|
||||
closeModal();
|
||||
btnSuccess(submitBtn, originalLabel);
|
||||
setTimeout(() => closeModal(), 700);
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.hidden = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = taskId ? 'Speichern' : 'Erstellen';
|
||||
submitBtn.textContent = originalLabel;
|
||||
btnError(submitBtn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +625,7 @@ function renderTaskList(container) {
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = renderTaskGroups(state.tasks, state.groupMode);
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
stagger(listEl.querySelectorAll('.swipe-row, .kanban-card'));
|
||||
updateOverdueBadge();
|
||||
wireSwipeGestures(container);
|
||||
}
|
||||
@@ -761,7 +782,7 @@ function wireSwipeGestures(container) {
|
||||
// Swipe links → Status-Toggle (offen ↔ erledigt)
|
||||
card.style.transition = 'transform 0.2s ease';
|
||||
card.style.transform = 'translateX(-110%)';
|
||||
if (navigator.vibrate) navigator.vibrate(40);
|
||||
vibrate(40);
|
||||
setTimeout(async () => {
|
||||
resetCard(false);
|
||||
try {
|
||||
@@ -776,7 +797,7 @@ function wireSwipeGestures(container) {
|
||||
} else if (dx > SWIPE_THRESHOLD) {
|
||||
// Swipe rechts → Bearbeiten-Modal
|
||||
resetCard(true);
|
||||
if (navigator.vibrate) navigator.vibrate(20);
|
||||
vibrate(20);
|
||||
try {
|
||||
const task = await loadTaskForEdit(taskId);
|
||||
openTaskModal({ task, users: state.users }, container);
|
||||
|
||||
+59
-32
@@ -80,11 +80,22 @@ async function importPage(pagePath) {
|
||||
// --------------------------------------------------------
|
||||
let currentUser = null;
|
||||
let currentPath = null;
|
||||
let isNavigating = false;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Router
|
||||
// --------------------------------------------------------
|
||||
|
||||
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/shopping',
|
||||
'/notes', '/contacts', '/budget', '/settings'];
|
||||
|
||||
function getDirection(fromPath, toPath) {
|
||||
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
|
||||
const toIdx = ROUTE_ORDER.indexOf(toPath);
|
||||
if (fromIdx === -1 || toIdx === -1 || fromPath === toPath) return 'right';
|
||||
return toIdx > fromIdx ? 'right' : 'left';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigiert zu einem Pfad und rendert die entsprechende Seite.
|
||||
* @param {string} path
|
||||
@@ -93,49 +104,61 @@ let currentPath = null;
|
||||
* @param {boolean} pushState - false beim initialen Load und popstate
|
||||
*/
|
||||
async function navigate(path, userOrPushState = true, pushState = true) {
|
||||
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
|
||||
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
|
||||
currentUser = userOrPushState;
|
||||
} else {
|
||||
pushState = userOrPushState;
|
||||
}
|
||||
if (isNavigating) return;
|
||||
isNavigating = true;
|
||||
|
||||
currentPath = path;
|
||||
try {
|
||||
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
|
||||
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
|
||||
currentUser = userOrPushState;
|
||||
} else {
|
||||
pushState = userOrPushState;
|
||||
}
|
||||
|
||||
const route = ROUTES.find((r) => r.path === path) ?? ROUTES.find((r) => r.path === '/');
|
||||
// Alten Pfad merken, bevor currentPath aktualisiert wird — für Richtungsberechnung
|
||||
const previousPath = currentPath;
|
||||
currentPath = path;
|
||||
|
||||
// Auth-Guard
|
||||
if (route.requiresAuth && !currentUser) {
|
||||
try {
|
||||
const result = await auth.me();
|
||||
currentUser = result.user;
|
||||
} catch {
|
||||
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
||||
navigate('/login');
|
||||
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 {
|
||||
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
||||
isNavigating = false;
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!route.requiresAuth && currentUser && path === '/login') {
|
||||
currentPath = null;
|
||||
isNavigating = false;
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!route.requiresAuth && currentUser && path === '/login') {
|
||||
currentPath = null;
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
if (pushState) {
|
||||
history.pushState({ path }, '', path);
|
||||
}
|
||||
|
||||
if (pushState) {
|
||||
history.pushState({ path }, '', path);
|
||||
await renderPage(route, previousPath);
|
||||
updateNav(path);
|
||||
updateThemeColorForRoute(route);
|
||||
} finally {
|
||||
isNavigating = false;
|
||||
}
|
||||
|
||||
await renderPage(route);
|
||||
updateNav(path);
|
||||
updateThemeColorForRoute(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt und rendert eine Seite dynamisch.
|
||||
* @param {{ path: string, page: string }} route
|
||||
* @param {string|null} previousPath - Pfad vor der Navigation (für Richtungsberechnung)
|
||||
*/
|
||||
async function renderPage(route) {
|
||||
async function renderPage(route, previousPath = null) {
|
||||
const app = document.getElementById('app');
|
||||
const loading = document.getElementById('app-loading');
|
||||
|
||||
@@ -158,18 +181,22 @@ async function renderPage(route) {
|
||||
|
||||
const content = document.getElementById('page-content') || app;
|
||||
|
||||
// Richtung bestimmen (previousPath ist der alte Pfad vor der Navigation)
|
||||
const direction = getDirection(previousPath, route.path);
|
||||
const outClass = direction === 'right' ? 'page-transition--out-left' : 'page-transition--out-right';
|
||||
const inClass = direction === 'right' ? 'page-transition--in-right' : 'page-transition--in-left';
|
||||
|
||||
// Alte Seite kurz ausfaden, falls vorhanden
|
||||
const oldPage = content.querySelector('.page-transition');
|
||||
if (oldPage) {
|
||||
oldPage.classList.add('page-transition--out');
|
||||
oldPage.classList.add(outClass);
|
||||
await new Promise(r => setTimeout(r, 120));
|
||||
}
|
||||
|
||||
// Seiten-Wrapper bereits jetzt in den DOM einfügen, damit
|
||||
// document.getElementById() in render() die richtigen Elemente findet.
|
||||
const pageWrapper = document.createElement('div');
|
||||
pageWrapper.className = 'page-transition';
|
||||
pageWrapper.style.animation = 'page-in 0.2s ease forwards';
|
||||
pageWrapper.className = `page-transition ${inClass}`;
|
||||
content.replaceChildren(pageWrapper);
|
||||
|
||||
await module.render(pageWrapper, { user: currentUser });
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.budget-page { --module-accent: var(--module-budget); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -198,7 +203,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
@@ -276,19 +281,6 @@
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.budget-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Budget-Modal Content-Styles (Overlay/Panel via shared modal.js)
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.contacts-page { --module-accent: var(--module-contacts); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -140,7 +145,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
@@ -216,17 +221,4 @@
|
||||
.contact-action-btn--mail:hover { background-color: var(--color-accent-light); color: var(--color-accent); }
|
||||
.contact-action-btn--maps:hover { background-color: var(--color-warning-light); color: var(--color-warning); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.contacts-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.dashboard {
|
||||
--module-accent: var(--module-dashboard);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Dashboard-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -761,7 +768,7 @@
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--module-accent, var(--color-btn-primary));
|
||||
background-color: var(--module-accent, var(--color-accent));
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
@@ -783,7 +790,7 @@
|
||||
}
|
||||
|
||||
.fab-main:hover {
|
||||
background-color: color-mix(in srgb, var(--module-accent, var(--color-btn-primary)) 85%, black);
|
||||
background-color: color-mix(in srgb, var(--module-accent, var(--color-accent)) 85%, black);
|
||||
}
|
||||
|
||||
.fab-main:active {
|
||||
|
||||
+138
-13
@@ -44,22 +44,51 @@
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Übergangs-Animation
|
||||
* Seiten-Übergangs-Animation (direktional)
|
||||
* -------------------------------------------------------- */
|
||||
@keyframes page-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes page-slide-in-right {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes page-slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes page-out-left {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(-20px); }
|
||||
}
|
||||
@keyframes page-out-right {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(20px); }
|
||||
}
|
||||
|
||||
@keyframes page-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
.page-transition--in-right {
|
||||
animation: page-slide-in-right 0.2s var(--ease-out) forwards;
|
||||
}
|
||||
|
||||
.page-transition--out {
|
||||
animation: page-out 0.12s ease forwards;
|
||||
.page-transition--in-left {
|
||||
animation: page-slide-in-left 0.2s var(--ease-out) forwards;
|
||||
}
|
||||
.page-transition--out-left {
|
||||
animation: page-out-left 0.12s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.page-transition--out-right {
|
||||
animation: page-out-right 0.12s ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-transition--in-right,
|
||||
.page-transition--in-left {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.page-transition--out-left,
|
||||
.page-transition--out-right {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Layout: Mobile (Standard, < 1024px)
|
||||
@@ -192,7 +221,7 @@
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--module-accent, var(--color-btn-primary));
|
||||
background-color: var(--module-accent, var(--color-accent));
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
@@ -206,7 +235,7 @@
|
||||
}
|
||||
|
||||
.page-fab:hover {
|
||||
background-color: color-mix(in srgb, var(--module-accent, var(--color-btn-primary)) 85%, black);
|
||||
background-color: color-mix(in srgb, var(--module-accent, var(--color-accent)) 85%, black);
|
||||
}
|
||||
|
||||
.page-fab:active {
|
||||
@@ -674,6 +703,45 @@
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Bottom Sheet: Handle + Closing Animation (Mobile < 768px) ── */
|
||||
@media (max-width: 767px) {
|
||||
.modal-panel {
|
||||
/* Extra top padding for the drag handle */
|
||||
padding-top: calc(var(--space-4) + 20px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.modal-panel--closing {
|
||||
animation: sheet-out 0.2s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sheet-out {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(100%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-panel {
|
||||
animation: none;
|
||||
}
|
||||
.modal-panel--closing {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Buttons
|
||||
* -------------------------------------------------------- */
|
||||
@@ -774,7 +842,7 @@
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-accent);
|
||||
background-color: var(--module-accent, var(--color-btn-primary));
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
@@ -788,6 +856,7 @@
|
||||
}
|
||||
|
||||
.fab:hover {
|
||||
background-color: color-mix(in srgb, var(--module-accent, var(--color-btn-primary)) 85%, black);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@@ -854,6 +923,35 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── Inline-Validierung ── */
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.form-field--error .input,
|
||||
.form-field--error .form-input {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.form-field--valid .input,
|
||||
.form-field--valid .form-input {
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.form-field__error {
|
||||
display: none;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-danger);
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-field--error .form-field__error {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Skeleton-Loading
|
||||
* -------------------------------------------------------- */
|
||||
@@ -906,6 +1004,14 @@
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.empty-state--compact {
|
||||
padding: var(--space-4) var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.empty-state--compact .empty-state__description {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Responsive Grid (Utility)
|
||||
* -------------------------------------------------------- */
|
||||
@@ -1209,6 +1315,25 @@
|
||||
border-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
/* ── Submit-Feedback Animationen ── */
|
||||
@keyframes btn-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-4px); }
|
||||
40% { transform: translateX(4px); }
|
||||
60% { transform: translateX(-4px); }
|
||||
80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
.btn--shaking {
|
||||
animation: btn-shake 0.3s ease;
|
||||
}
|
||||
|
||||
.btn--success {
|
||||
background-color: var(--color-success) !important;
|
||||
color: #fff !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Print-Styles
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
+9
-17
@@ -4,6 +4,11 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.meals-page { --module-accent: var(--module-meals); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -180,7 +185,7 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
padding: var(--space-1) var(--space-2) var(--space-2);
|
||||
padding: var(--space-4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -406,21 +411,8 @@
|
||||
.meal-type-badge--snack { background: var(--meal-snack-light); color: var(--meal-snack); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* Leer-Zustand (leere Slots im Wochenplan)
|
||||
* -------------------------------------------------------- */
|
||||
.meals-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meals-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--color-text-disabled);
|
||||
margin-bottom: var(--space-4);
|
||||
.meal-slot--empty {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
+5
-20
@@ -4,6 +4,11 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.notes-page { --module-accent: var(--module-notes); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -206,26 +211,6 @@
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.notes-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
column-span: all; /* Multicolumn: volle Breite */
|
||||
}
|
||||
|
||||
.notes-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--color-text-disabled);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Notes-Modal Content-Styles (Overlay/Panel via shared modal.js)
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.shopping-page { --module-accent: var(--module-shopping); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -411,36 +416,6 @@
|
||||
|
||||
.item-delete:hover { color: var(--color-danger); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.shopping-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-12) var(--space-6);
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shopping-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--color-text-disabled);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.shopping-empty__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.shopping-empty__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* No-Lists-Zustand
|
||||
|
||||
+6
-26
@@ -4,6 +4,11 @@
|
||||
* Abhängigkeiten: tokens.css, layout.css
|
||||
*/
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Modul-Akzent
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-page { --module-accent: var(--module-tasks); }
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Seiten-Layout
|
||||
* -------------------------------------------------------- */
|
||||
@@ -248,7 +253,7 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
/* Status-Checkbox */
|
||||
@@ -624,29 +629,4 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Leer-Zustand
|
||||
* -------------------------------------------------------- */
|
||||
.tasks-empty {
|
||||
padding: var(--space-12) var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tasks-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--color-text-disabled);
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.tasks-empty__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.tasks-empty__desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Modul: UX Utilities
|
||||
* Zweck: Wiederverwendbare Animationshelfer (Stagger, Vibration)
|
||||
* Abhängigkeiten: keine
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gestaffeltes Einblenden einer NodeList oder eines Arrays von Elementen.
|
||||
* Maximal MAX_STAGGER Elemente werden verzögert, der Rest sofort eingeblendet.
|
||||
*
|
||||
* @param {NodeList|Element[]} elements
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.delay=30] — ms zwischen jedem Element
|
||||
* @param {number} [opts.duration=180] — ms pro Element
|
||||
* @param {number} [opts.max=5] — Maximale Anzahl gestaffelter Elemente
|
||||
*/
|
||||
export function stagger(elements, { delay = 30, duration = 180, max = 5 } = {}) {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const els = Array.from(elements);
|
||||
els.forEach((el, i) => {
|
||||
const itemDelay = i < max ? i * delay : max * delay;
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(8px)';
|
||||
el.style.transition = `opacity ${duration}ms ease, transform ${duration}ms ease`;
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'translateY(0)';
|
||||
}, itemDelay);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrationsmuster abspielen, wenn die API verfügbar ist und
|
||||
* keine reduzierte Bewegung gewünscht wird.
|
||||
*
|
||||
* @param {number|number[]} pattern — ms oder [an, aus, an, ...]-Array
|
||||
*/
|
||||
export function vibrate(pattern) {
|
||||
if (!navigator.vibrate) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
navigator.vibrate(pattern);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Tests: UX Utilities (stagger, vibrate)
|
||||
* Läuft im Node-Kontext — kein DOM verfügbar, daher nur Pure-Logic-Tests.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Minimales Window/Navigator-Mock für Node
|
||||
const { stagger, vibrate } = await (async () => {
|
||||
// stagger braucht window.matchMedia — wir mocken es
|
||||
global.window = {
|
||||
matchMedia: () => ({ matches: false }),
|
||||
};
|
||||
// navigator ist in Node ein getter-only property — über defineProperty überschreiben
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
value: { vibrate: null },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return import('./public/utils/ux.js');
|
||||
})();
|
||||
|
||||
test('stagger: setzt opacity:0 auf alle Elemente', () => {
|
||||
const els = [{ style: {} }, { style: {} }, { style: {} }];
|
||||
stagger(els, { delay: 0, duration: 0 });
|
||||
assert.equal(els[0].style.opacity, '0');
|
||||
assert.equal(els[1].style.opacity, '0');
|
||||
assert.equal(els[2].style.opacity, '0');
|
||||
});
|
||||
|
||||
test('stagger: tut nichts bei prefers-reduced-motion', () => {
|
||||
global.window.matchMedia = () => ({ matches: true });
|
||||
const els = [{ style: {} }];
|
||||
stagger(els);
|
||||
assert.equal(els[0].style.opacity, undefined); // unverändert
|
||||
global.window.matchMedia = () => ({ matches: false }); // reset
|
||||
});
|
||||
|
||||
test('vibrate: tut nichts wenn API nicht vorhanden', () => {
|
||||
Object.defineProperty(global, 'navigator', { value: { vibrate: null }, writable: true, configurable: true });
|
||||
assert.doesNotThrow(() => vibrate(10));
|
||||
});
|
||||
|
||||
test('vibrate: ruft navigator.vibrate auf wenn vorhanden', () => {
|
||||
let called = null;
|
||||
Object.defineProperty(global, 'navigator', { value: { vibrate: (p) => { called = p; } }, writable: true, configurable: true });
|
||||
vibrate(15);
|
||||
assert.equal(called, 15);
|
||||
});
|
||||
Reference in New Issue
Block a user