fix: modal onClose callback, calendar popup truncation, datetime validation

- modal.js: add onClose callback to openModal(), fix _initialFormTimeout
  cleanup, deduplicate escape/overlay-click handlers in confirmModal,
  promptModal, selectModal using the new callback
- calendar.js: replace popup.innerHTML with insertAdjacentHTML (constraint),
  add truncateDescription() to cap long event descriptions at 500 chars
- validate.js: extend DATETIME_RE to cover ISO 8601 with ms/timezone,
  normalise datetime values to YYYY-MM-DDTHH:MM on input
- index.js: include app version in startup log message
- docker-compose.yml / .env.example: switch from named volume to
  host-mounted DATA_DIR/BACKUP_DIR with sane defaults
- docs/installation.md: document host-mount data paths

Co-Authored-By: Rafael Foster <rafaelfoster@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-06 07:00:07 +02:00
parent 56b2fd471d
commit b545d83f64
7 changed files with 101 additions and 172 deletions
+4 -1
View File
@@ -6,6 +6,10 @@ PORT=3000
NODE_ENV=production NODE_ENV=production
# LOG_LEVEL=info # debug, info, warn, error (default: info) # LOG_LEVEL=info # debug, info, warn, error (default: info)
# Docker storage paths
# DATA_DIR=./data # Host folder for the database and stored app data
# BACKUP_DIR=./backups # Host folder for scheduled backups
# Session # Session
SESSION_SECRET=REPLACE_WITH_A_LONG_RANDOM_STRING SESSION_SECRET=REPLACE_WITH_A_LONG_RANDOM_STRING
# SESSION_SECURE=false # Only set when not using HTTPS/reverse proxy (e.g. direct localhost) # SESSION_SECURE=false # Only set when not using HTTPS/reverse proxy (e.g. direct localhost)
@@ -36,7 +40,6 @@ SYNC_INTERVAL_MINUTES=15
# Automatic Backups # Automatic Backups
# BACKUP_ENABLED=true # Enable/disable automated backups (default: true) # BACKUP_ENABLED=true # Enable/disable automated backups (default: true)
# BACKUP_SCHEDULE=0 2 * * * # Cron schedule (default: 2 AM daily) # BACKUP_SCHEDULE=0 2 * * * # Cron schedule (default: 2 AM daily)
# BACKUP_DIR=./backups # Backup directory (default: ./backups)
# BACKUP_KEEP=7 # Number of backups to keep (default: 7) # BACKUP_KEEP=7 # Number of backups to keep (default: 7)
# TZ=Europe/Berlin # Timezone for scheduled backups (default: UTC) # TZ=Europe/Berlin # Timezone for scheduled backups (default: UTC)
+4 -6
View File
@@ -7,12 +7,14 @@ services:
ports: ports:
- "0.0.0.0:3000:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
- oikos_data:/data - ${DATA_DIR:-./data}:/data
- ${BACKUP_DIR:-./backups}:/backups
env_file: env_file:
- .env - .env
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DB_PATH=/data/oikos.db - DB_PATH=${DB_PATH:-/data/oikos.db}
- BACKUP_DIR=${BACKUP_DIR:-/backups}
# Reverse proxy setup (Caddy, nginx, Traefik): # Reverse proxy setup (Caddy, nginx, Traefik):
# - Remove SESSION_SECURE=false (default is true) # - Remove SESSION_SECURE=false (default is true)
# - TRUST_PROXY is automatically set to 1 (trust one proxy hop) # - TRUST_PROXY is automatically set to 1 (trust one proxy hop)
@@ -24,7 +26,3 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
volumes:
oikos_data:
driver: local
+11 -2
View File
@@ -59,7 +59,7 @@ Complete setup instructions for Oikos - from Docker installation to your first l
## Architecture Overview ## Architecture Overview
Oikos is a self-hosted family planner that runs as a single Docker container. The Express.js backend serves both the API and the static frontend files. All data is stored in a SQLCipher-encrypted SQLite database inside a Docker volume. Oikos is a self-hosted family planner that runs as a single Docker container. The Express.js backend serves both the API and the static frontend files. All data is stored in a SQLCipher-encrypted SQLite database inside a host-mounted data folder, and automated backups are written to a separate host-mounted backup folder.
``` ```
Browser ──HTTP──▶ Docker Container (Express.js :3000) ──▶ SQLite/SQLCipher (/data/oikos.db) Browser ──HTTP──▶ Docker Container (Express.js :3000) ──▶ SQLite/SQLCipher (/data/oikos.db)
@@ -453,7 +453,9 @@ docker compose up -d --build
### Where is the Data? ### Where is the Data?
The SQLite database lives in a Docker named volume called `oikos_data`, mounted at `/data` inside the container. The database file is `/data/oikos.db`. The SQLite database lives in the host folder configured through `DATA_DIR` and is mounted at `/data` inside the container. The database file is `/data/oikos.db`.
Scheduled backups are written to the host folder configured through `BACKUP_DIR` and mounted at `/backups` inside the container.
### Backup ### Backup
@@ -466,6 +468,13 @@ docker cp oikos:/data/oikos-backup.db ./oikos-backup-$(date +%Y%m%d).db
Admins can also download a backup from **Settings → Backup Management**. Admins can also download a backup from **Settings → Backup Management**.
If you want to store the database and backups in specific local folders, set these in `.env` before starting Compose:
```bash
DATA_DIR=./data
BACKUP_DIR=./backups
```
### Restore ### Restore
Admins can restore a backup from **Settings → Backup Management**. For operational restores via Docker Compose, stop the running app, mount the backup into a temporary container that uses the same Docker volume, and replace the database file: Admins can restore a backup from **Settings → Backup Management**. For operational restores via Docker Compose, stop the running app, mount the backup into a temporary container that uses the same Docker volume, and replace the database file:
+58 -154
View File
@@ -7,7 +7,7 @@
* i18n.js (t) * i18n.js (t)
* *
* API: * API:
* openModal({ title, content, onSave, onDelete, size }) → void * openModal({ title, content, onSave, onDelete, onClose, size }) → void
* closeModal() → void * closeModal() → void
*/ */
@@ -17,6 +17,7 @@ let activeOverlay = null;
let previouslyFocused = null; let previouslyFocused = null;
let focusTrapHandler = null; let focusTrapHandler = null;
let _initialFormSnapshot = null; let _initialFormSnapshot = null;
let _initialFormTimeout = null;
let _isClosing = false; let _isClosing = false;
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
@@ -110,7 +111,7 @@ function serializeForm(container) {
} }
function isFormDirty(container) { function isFormDirty(container) {
if (!_initialFormSnapshot) return false; if (_initialFormSnapshot === null) return false;
return serializeForm(container) !== _initialFormSnapshot; return serializeForm(container) !== _initialFormSnapshot;
} }
@@ -180,7 +181,6 @@ function _doClose(overlayEl) {
target.remove(); target.remove();
// Globalen State nur zurücksetzen wenn kein neues Modal zwischenzeitlich geöffnet wurde. // Globalen State nur zurücksetzen wenn kein neues Modal zwischenzeitlich geöffnet wurde.
// (activeOverlay !== target bedeutet: openModal hat bereits ein neues Modal registriert)
if (activeOverlay === target) { if (activeOverlay === target) {
activeOverlay = null; activeOverlay = null;
@@ -211,18 +211,15 @@ function _doClose(overlayEl) {
* @param {string} opts.title - Titel im Modal-Header * @param {string} opts.title - Titel im Modal-Header
* @param {string} opts.content - HTML-String für den Modal-Body * @param {string} opts.content - HTML-String für den Modal-Body
* @param {Function} [opts.onSave] - Callback, wird nach Einfügen in DOM aufgerufen * @param {Function} [opts.onSave] - Callback, wird nach Einfügen in DOM aufgerufen
* (zum Binden von Form-Events) * @param {Function} [opts.onClose] - Callback, wird aufgerufen wenn das Modal geschlossen wird
* @param {Function} [opts.onDelete] - Falls vorhanden, wird ein Löschen-Button eingebaut * @param {Function} [opts.onDelete] - Falls vorhanden, wird ein Löschen-Button eingebaut
* @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg' * @param {string} [opts.size='md'] - 'sm' | 'md' | 'lg'
*/ */
export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}) { export function openModal({ title, content, onSave, onDelete, onClose, size = 'md' } = {}) {
// Vorheriges Modal schließen (kein Stacking). // Vorheriges Modal schließen (kein Stacking).
// ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals
// nicht die noch animierende alte Instanz zurückgibt sonst landen alle
// Event-Listener am falschen Element und Buttons reagieren nicht.
// force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich).
if (activeOverlay) { if (activeOverlay) {
activeOverlay.removeAttribute('id'); activeOverlay.removeAttribute('id');
// force:true ensures we don't trigger another dirty check while opening a new modal
closeModal({ force: true }); closeModal({ force: true });
} }
@@ -252,6 +249,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
document.body.insertAdjacentHTML('beforeend', html); document.body.insertAdjacentHTML('beforeend', html);
activeOverlay = document.getElementById('shared-modal-overlay'); activeOverlay = document.getElementById('shared-modal-overlay');
activeOverlay._onCloseCallback = onClose;
// Lucide-Icons rendern // Lucide-Icons rendern
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
@@ -261,8 +259,9 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
trapFocus(panel); trapFocus(panel);
// Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden) // Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden)
if (_initialFormTimeout) clearTimeout(_initialFormTimeout);
_initialFormSnapshot = null; _initialFormSnapshot = null;
setTimeout(() => { _initialFormTimeout = setTimeout(() => {
if (activeOverlay) { if (activeOverlay) {
_initialFormSnapshot = serializeForm(activeOverlay.querySelector('.modal-panel') ?? activeOverlay); _initialFormSnapshot = serializeForm(activeOverlay.querySelector('.modal-panel') ?? activeOverlay);
} }
@@ -278,8 +277,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
if (e.target === activeOverlay) closeModal(); if (e.target === activeOverlay) closeModal();
}); });
// iOS PWA: click-Events auf non-interactive divs sind unzuverlässig → // iOS PWA: touchend als Fallback
// touchend als Fallback (passive, damit Scroll nicht blockiert wird)
activeOverlay.addEventListener('touchend', (e) => { activeOverlay.addEventListener('touchend', (e) => {
if (e.target === activeOverlay) closeModal(); if (e.target === activeOverlay) closeModal();
}, { passive: true }); }, { passive: true });
@@ -288,15 +286,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
activeOverlay.querySelector('[data-action="close-modal"]') activeOverlay.querySelector('[data-action="close-modal"]')
?.addEventListener('click', () => closeModal()); ?.addEventListener('click', () => closeModal());
// Escape // Escape (nur einmal binden)
document.removeEventListener('keydown', onEscape);
document.addEventListener('keydown', onEscape); document.addEventListener('keydown', onEscape);
// Callback für Aufrufer (Form-Events binden etc.) // Callback für Aufrufer
if (typeof onSave === 'function') onSave(panel); if (typeof onSave === 'function') onSave(panel);
// Loading-State: btn--loading auf Submit-Button während async-Save. // Loading-State
// rAF-Check: Validierung schlägt fehl → btn bleibt enabled → Loading sofort entfernen.
// MutationObserver: Error-Pfad → btn wird re-enabled → Loading entfernen.
panel.addEventListener('submit', (e) => { panel.addEventListener('submit', (e) => {
const btn = e.target.querySelector('[type="submit"], .btn--primary'); const btn = e.target.querySelector('[type="submit"], .btn--primary');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
@@ -310,7 +307,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
}); });
}, { capture: true }); }, { capture: true });
// Standalone: Statusbar abdunkeln (Overlay-Effekt) // Standalone: Statusbar abdunkeln
if (window.oikos?.setThemeColor) { if (window.oikos?.setThemeColor) {
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
} }
@@ -321,53 +318,61 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// -------------------------------------------------------- // --------------------------------------------------------
export async function closeModal({ force = false } = {}) { export async function closeModal({ force = false } = {}) {
// If already closing, ignore call
if (!activeOverlay || _isClosing) return; if (!activeOverlay || _isClosing) return;
_isClosing = true;
if (!force) { if (!force) {
const panel = activeOverlay.querySelector('.modal-panel'); const panel = activeOverlay.querySelector('.modal-panel');
if (panel && isFormDirty(panel)) { if (panel && isFormDirty(panel)) {
const dirtyOverlay = activeOverlay; const dirtyOverlay = activeOverlay;
const dirtySnapshot = _initialFormSnapshot; const dirtySnapshot = _initialFormSnapshot;
let confirmed; const overlayId = dirtyOverlay.id;
try {
// Momentarily clear global state to allow confirmModal to open without deadlock
dirtyOverlay.removeAttribute('id');
activeOverlay = null; activeOverlay = null;
_isClosing = false;
confirmed = await confirmModal(t('modal.unsavedChanges'), { const confirmed = await confirmModal(t('modal.unsavedChanges'), {
danger: false, danger: false,
confirmLabel: t('modal.discardChanges'), confirmLabel: t('modal.discardChanges'),
}); });
} catch (err) {
activeOverlay = dirtyOverlay;
_initialFormSnapshot = dirtySnapshot;
_isClosing = false;
throw err;
}
activeOverlay = dirtyOverlay;
_initialFormSnapshot = dirtySnapshot;
if (!confirmed) { if (!confirmed) {
// Restore previous modal state if user cancelled discard
if (overlayId) dirtyOverlay.id = overlayId;
activeOverlay = dirtyOverlay;
_initialFormSnapshot = dirtySnapshot;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
if (window.oikos?.setThemeColor) { if (window.oikos?.setThemeColor) {
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
} }
_isClosing = false;
return; return;
} }
_isClosing = true;
// User confirmed discard, re-assign activeOverlay so the rest of the logic cleans it up
activeOverlay = dirtyOverlay;
} }
} }
// Final closing phase starts here
_isClosing = true;
if (_initialFormTimeout) {
clearTimeout(_initialFormTimeout);
_initialFormTimeout = null;
}
_initialFormSnapshot = null; _initialFormSnapshot = null;
document.removeEventListener('keydown', onEscape); document.removeEventListener('keydown', onEscape);
// Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal
// bevor animationend feuert. Ohne capturedOverlay würde _doClose() das neue Modal
// statt des alten entfernen (Race Condition → Buttons im Confirm-Dialog reagieren nicht).
const capturedOverlay = activeOverlay; const capturedOverlay = activeOverlay;
const panel = capturedOverlay.querySelector('.modal-panel'); const panel = capturedOverlay.querySelector('.modal-panel');
// Focus-Trap-Handler und Virtual-Keyboard-Listener entfernen if (typeof capturedOverlay._onCloseCallback === 'function') {
capturedOverlay._onCloseCallback();
}
// Focus-Trap Cleanup
if (focusTrapHandler) { if (focusTrapHandler) {
if (panel) panel.removeEventListener('keydown', focusTrapHandler); if (panel) panel.removeEventListener('keydown', focusTrapHandler);
focusTrapHandler = null; focusTrapHandler = null;
@@ -376,12 +381,14 @@ export async function closeModal({ force = false } = {}) {
panel.removeEventListener('focusin', panel._onInputFocus); panel.removeEventListener('focusin', panel._onInputFocus);
} }
// Sheet-Out-Animation auf Mobile, danach _doClose // Animation handling
const isMobile = window.innerWidth < 768; const isMobile = window.innerWidth < 768;
if (isMobile && panel) { if (isMobile && panel) {
panel.classList.add('modal-panel--closing'); panel.classList.add('modal-panel--closing');
// Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.) const fallback = setTimeout(() => {
const fallback = setTimeout(() => { _isClosing = false; _doClose(capturedOverlay); }, 300); _isClosing = false;
_doClose(capturedOverlay);
}, 400); // Slightly longer fallback
panel.addEventListener('animationend', () => { panel.addEventListener('animationend', () => {
clearTimeout(fallback); clearTimeout(fallback);
_isClosing = false; _isClosing = false;
@@ -395,17 +402,9 @@ export async function closeModal({ force = false } = {}) {
} }
// -------------------------------------------------------- // --------------------------------------------------------
// promptModal - Ersatz für native prompt() // promptModal
// -------------------------------------------------------- // --------------------------------------------------------
/**
* Öffnet ein Modal mit Textfeld als Ersatz für native prompt().
* Gibt ein Promise zurück: string bei OK, null bei Cancel/Escape.
*
* @param {string} label - Beschriftung / Frage
* @param {string} [defaultValue=''] - Vorausgefüllter Wert
* @returns {Promise<string|null>}
*/
export function promptModal(label, defaultValue = '') { export function promptModal(label, defaultValue = '') {
return new Promise((resolve) => { return new Promise((resolve) => {
let resolved = false; let resolved = false;
@@ -413,7 +412,7 @@ export function promptModal(label, defaultValue = '') {
function finish(value) { function finish(value) {
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
closeModal(); closeModal({ force: true });
resolve(value); resolve(value);
} }
@@ -431,6 +430,7 @@ export function promptModal(label, defaultValue = '') {
<button type="submit" class="btn btn--primary" id="prompt-modal-ok">${t('common.save')}</button> <button type="submit" class="btn btn--primary" id="prompt-modal-ok">${t('common.save')}</button>
</div> </div>
</form>`, </form>`,
onClose: () => finish(null),
onSave(panel) { onSave(panel) {
const form = panel.querySelector('#prompt-modal-form'); const form = panel.querySelector('#prompt-modal-form');
const input = panel.querySelector('#prompt-modal-input'); const input = panel.querySelector('#prompt-modal-input');
@@ -443,24 +443,6 @@ export function promptModal(label, defaultValue = '') {
cancel.addEventListener('click', () => finish(null)); cancel.addEventListener('click', () => finish(null));
// Escape soll null liefern (closeModal wird über onEscape bereits ausgelöst)
const escHandler = (e) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
finish(null);
}
};
document.addEventListener('keydown', escHandler);
// Overlay-Click soll null liefern
const overlay = panel.closest('.modal-overlay');
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) finish(null);
});
}
// Input fokussieren und Text selektieren
setTimeout(() => { setTimeout(() => {
input.focus(); input.focus();
input.select(); input.select();
@@ -471,16 +453,9 @@ export function promptModal(label, defaultValue = '') {
} }
// -------------------------------------------------------- // --------------------------------------------------------
// selectModal - Ersatz für native prompt() mit Auswahlliste // selectModal
// -------------------------------------------------------- // --------------------------------------------------------
/**
* Öffnet ein Modal mit Select-Dropdown als Ersatz für native prompt() bei Listenauswahl.
*
* @param {string} label - Beschriftung / Frage
* @param {{ value: string|number, label: string }[]} options - Auswahloptionen
* @returns {Promise<string|number|null>}
*/
export function selectModal(label, options) { export function selectModal(label, options) {
return new Promise((resolve) => { return new Promise((resolve) => {
let resolved = false; let resolved = false;
@@ -488,7 +463,7 @@ export function selectModal(label, options) {
function finish(value) { function finish(value) {
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
closeModal(); closeModal({ force: true });
resolve(value); resolve(value);
} }
@@ -509,6 +484,7 @@ export function selectModal(label, options) {
<button type="submit" class="btn btn--primary" id="select-modal-ok">${t('common.save')}</button> <button type="submit" class="btn btn--primary" id="select-modal-ok">${t('common.save')}</button>
</div> </div>
</form>`, </form>`,
onClose: () => finish(null),
onSave(panel) { onSave(panel) {
const form = panel.querySelector('#select-modal-form'); const form = panel.querySelector('#select-modal-form');
const select = panel.querySelector('#select-modal-input'); const select = panel.querySelector('#select-modal-input');
@@ -520,40 +496,15 @@ export function selectModal(label, options) {
}); });
cancel.addEventListener('click', () => finish(null)); cancel.addEventListener('click', () => finish(null));
const escHandler = (e) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
finish(null);
}
};
document.addEventListener('keydown', escHandler);
const overlay = panel.closest('.modal-overlay');
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) finish(null);
});
}
}, },
}); });
}); });
} }
// -------------------------------------------------------- // --------------------------------------------------------
// confirmModal - Ersatz für native confirm() // confirmModal
// -------------------------------------------------------- // --------------------------------------------------------
/**
* Zeigt ein Bestätigungs-Modal als Ersatz für native confirm().
* Gibt ein Promise zurück: true bei OK, false bei Cancel/Escape/Overlay-Klick.
*
* @param {string} message - Frage / Meldung im Titel
* @param {Object} [opts]
* @param {string} [opts.confirmLabel] - Text des Bestätigungs-Buttons (default: t('common.confirm'))
* @param {boolean} [opts.danger=false] - Roten Danger-Button statt Primary verwenden
* @returns {Promise<boolean>}
*/
export function confirmModal(message, { confirmLabel, danger = false } = {}) { export function confirmModal(message, { confirmLabel, danger = false } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
let resolved = false; let resolved = false;
@@ -561,7 +512,7 @@ export function confirmModal(message, { confirmLabel, danger = false } = {}) {
function finish(value) { function finish(value) {
if (resolved) return; if (resolved) return;
resolved = true; resolved = true;
closeModal(); closeModal({ force: true });
resolve(value); resolve(value);
} }
@@ -575,31 +526,17 @@ export function confirmModal(message, { confirmLabel, danger = false } = {}) {
${confirmLabel ?? t('common.confirm')} ${confirmLabel ?? t('common.confirm')}
</button> </button>
</div>`, </div>`,
onClose: () => finish(false),
onSave(panel) { onSave(panel) {
panel.querySelector('#confirm-modal-ok')?.addEventListener('click', () => finish(true)); panel.querySelector('#confirm-modal-ok')?.addEventListener('click', () => finish(true));
panel.querySelector('#confirm-modal-cancel')?.addEventListener('click', () => finish(false)); panel.querySelector('#confirm-modal-cancel')?.addEventListener('click', () => finish(false));
const escHandler = (e) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
finish(false);
}
};
document.addEventListener('keydown', escHandler);
const overlay = panel.closest('.modal-overlay');
if (overlay) {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) finish(false);
});
}
}, },
}); });
}); });
} }
// -------------------------------------------------------- // --------------------------------------------------------
// Inline Blur-Validierung // Validation & Feedback
// -------------------------------------------------------- // --------------------------------------------------------
function _validateField(input) { function _validateField(input) {
@@ -625,23 +562,12 @@ function _validateField(input) {
return hasValue; return hasValue;
} }
/**
* Aktiviert Blur-Validierung für alle required-Inputs in einem Container.
* @param {HTMLElement} formContainer
*/
export function wireBlurValidation(formContainer) { export function wireBlurValidation(formContainer) {
formContainer.querySelectorAll('input[required], select[required], textarea[required]').forEach((input) => { formContainer.querySelectorAll('input[required], select[required], textarea[required]').forEach((input) => {
input.addEventListener('blur', () => _validateField(input)); input.addEventListener('blur', () => _validateField(input));
}); });
} }
/**
* Validiert alle required-Inputs sofort (z.B. beim Submit ohne vorangehendes Blur).
* Markiert Fehler inline und fokussiert das erste ungültige Feld.
*
* @param {HTMLElement} formContainer
* @returns {boolean} true wenn alle Felder valide sind
*/
export function validateAll(formContainer) { export function validateAll(formContainer) {
let firstInvalid = null; let firstInvalid = null;
let allValid = true; let allValid = true;
@@ -656,16 +582,6 @@ export function validateAll(formContainer) {
return allValid; return allValid;
} }
// --------------------------------------------------------
// Submit-Feedback (Checkmark + Shake)
// --------------------------------------------------------
/**
* Zeigt Erfolgs-Feedback auf einem Button (Checkmark für 700ms).
* Respektiert prefers-reduced-motion: zeigt nur Farb-Feedback ohne Icon-Wechsel.
* @param {HTMLButtonElement} btn
* @param {string} [originalLabel]
*/
export function btnSuccess(btn, originalLabel) { export function btnSuccess(btn, originalLabel) {
btn.classList.remove('btn--loading'); btn.classList.remove('btn--loading');
const label = originalLabel ?? btn.textContent; const label = originalLabel ?? btn.textContent;
@@ -691,13 +607,6 @@ export function btnSuccess(btn, originalLabel) {
}, 700); }, 700);
} }
/**
* Versetzt einen Button in den Lade-Zustand (Spinner, nicht klickbar).
* Gibt eine Cleanup-Funktion zurück, die den Originalzustand wiederherstellt.
*
* @param {HTMLButtonElement} btn
* @returns {() => void} cleanup
*/
export function btnLoading(btn) { export function btnLoading(btn) {
btn.classList.add('btn--loading'); btn.classList.add('btn--loading');
btn.disabled = true; btn.disabled = true;
@@ -707,11 +616,6 @@ export function btnLoading(btn) {
}; };
} }
/**
* Zeigt Fehler-Feedback auf einem Button (Shake-Animation).
* Respektiert prefers-reduced-motion: kein visuelles Schütteln, nur Farb-Feedback.
* @param {HTMLButtonElement} btn
*/
export function btnError(btn) { export function btnError(btn) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
btn.classList.add('btn--error-static'); btn.classList.add('btn--error-static');
@@ -719,7 +623,7 @@ export function btnError(btn) {
return; return;
} }
btn.classList.remove('btn--shaking'); btn.classList.remove('btn--shaking');
void btn.offsetWidth; // Reflow für Animation-Restart void btn.offsetWidth;
btn.classList.add('btn--shaking'); btn.classList.add('btn--shaking');
btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true }); btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true });
} }
+10 -3
View File
@@ -402,6 +402,13 @@ function attachmentHtml(event) {
</a>`; </a>`;
} }
function truncateDescription(description, maxLength = 500) {
const text = String(description || '').trim();
if (!text) return '';
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength)} (...)`;
}
function attachmentPreviewHtml(event) { function attachmentPreviewHtml(event) {
if (!event?.attachment_data) return ''; if (!event?.attachment_data) return '';
const name = esc(event.attachment_name || t('calendar.attachmentFallback')); const name = esc(event.attachment_name || t('calendar.attachmentFallback'));
@@ -1067,14 +1074,14 @@ function showEventPopup(ev, anchor) {
+ (ev.end_datetime ? ` ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : ''); + (ev.end_datetime ? ` ${formatTime(ev.end_datetime)}${t('calendar.timeSuffix') ? ' ' + t('calendar.timeSuffix') : ''}`.trim() : '');
const displayColor = ev.cal_color || ev.color; const displayColor = ev.cal_color || ev.color;
popup.innerHTML = ` popup.insertAdjacentHTML('beforeend', `
<div class="event-popup__color-bar" style="background-color:${esc(displayColor)};"></div> <div class="event-popup__color-bar" style="background-color:${esc(displayColor)};"></div>
<div class="event-popup__title">${eventIconHtml(ev.icon)}<span>${esc(ev.title)}</span></div> <div class="event-popup__title">${eventIconHtml(ev.icon)}<span>${esc(ev.title)}</span></div>
<div class="event-popup__meta"> <div class="event-popup__meta">
${ev.cal_name ? `<div><span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span></div>` : ''} ${ev.cal_name ? `<div><span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span></div>` : ''}
<div>${timeStr}</div> <div>${timeStr}</div>
${ev.location ? `<div>📍 ${esc(fmtLocation(ev.location))}</div>` : ''} ${ev.location ? `<div>📍 ${esc(fmtLocation(ev.location))}</div>` : ''}
${ev.description ? `<div>${esc(ev.description)}</div>` : ''} ${ev.description ? `<div>${esc(truncateDescription(ev.description, 500))}</div>` : ''}
${ev.attachment_data ? attachmentHtml(ev) : ''} ${ev.attachment_data ? attachmentHtml(ev) : ''}
${ev.assigned_name ? `<div>👤 ${esc(ev.assigned_name)}</div>` : ''} ${ev.assigned_name ? `<div>👤 ${esc(ev.assigned_name)}</div>` : ''}
</div> </div>
@@ -1084,7 +1091,7 @@ function showEventPopup(ev, anchor) {
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i> <i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button> </button>
</div> </div>
`; `);
document.body.appendChild(popup); document.body.appendChild(popup);
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
+1 -1
View File
@@ -268,7 +268,7 @@ async function runSync() {
// Server starten // Server starten
// -------------------------------------------------------- // --------------------------------------------------------
app.listen(PORT, () => { app.listen(PORT, () => {
logOikos.info(`Server running on port ${PORT}`); logOikos.info(`Server running on port ${PORT} | Version ${APP_VERSION}`);
logOikos.info(`Environment: ${process.env.NODE_ENV || 'development'}`); logOikos.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
// Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert) // Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
+10 -2
View File
@@ -13,7 +13,7 @@ const MAX_RRULE = 300;
// Regex-Muster // Regex-Muster
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const TIME_RE = /^\d{2}:\d{2}$/; const TIME_RE = /^\d{2}:\d{2}$/;
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?Z?)?$/; const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
const COLOR_RE = /^#[0-9A-Fa-f]{6}$/; const COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
const MONTH_RE = /^\d{4}-\d{2}$/; const MONTH_RE = /^\d{4}-\d{2}$/;
const RRULE_RE = /^(FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=\d{1,2})?(;BYDAY=[A-Z,]{2,}(,[A-Z]{2})*)?(;UNTIL=\d{8}(T\d{6}Z)?)?)?$/; const RRULE_RE = /^(FREQ=(DAILY|WEEKLY|MONTHLY)(;INTERVAL=\d{1,2})?(;BYDAY=[A-Z,]{2,}(,[A-Z]{2})*)?(;UNTIL=\d{8}(T\d{6}Z)?)?)?$/;
@@ -120,7 +120,15 @@ function datetime(val, field, required = false) {
} }
if (!DATETIME_RE.test(String(val))) if (!DATETIME_RE.test(String(val)))
return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` }; return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` };
return { value: String(val), error: null }; const raw = String(val).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return { value: raw, error: null };
const match = raw.match(
/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/
);
if (!match) {
return { value: null, error: `${field} must be in YYYY-MM-DD or YYYY-MM-DDTHH:MM format.` };
}
return { value: `${match[1]}T${match[2]}:${match[3]}`, error: null };
} }
/** /**