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:
+4
-1
@@ -6,6 +6,10 @@ PORT=3000
|
||||
NODE_ENV=production
|
||||
# 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_SECRET=REPLACE_WITH_A_LONG_RANDOM_STRING
|
||||
# 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
|
||||
# BACKUP_ENABLED=true # Enable/disable automated backups (default: true)
|
||||
# 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)
|
||||
# TZ=Europe/Berlin # Timezone for scheduled backups (default: UTC)
|
||||
|
||||
|
||||
+4
-6
@@ -7,12 +7,14 @@ services:
|
||||
ports:
|
||||
- "0.0.0.0:3000:3000"
|
||||
volumes:
|
||||
- oikos_data:/data
|
||||
- ${DATA_DIR:-./data}:/data
|
||||
- ${BACKUP_DIR:-./backups}:/backups
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- 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):
|
||||
# - Remove SESSION_SECURE=false (default is true)
|
||||
# - TRUST_PROXY is automatically set to 1 (trust one proxy hop)
|
||||
@@ -24,7 +26,3 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
oikos_data:
|
||||
driver: local
|
||||
|
||||
+11
-2
@@ -59,7 +59,7 @@ Complete setup instructions for Oikos - from Docker installation to your first l
|
||||
|
||||
## 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)
|
||||
@@ -453,7 +453,9 @@ docker compose up -d --build
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
+61
-157
@@ -7,7 +7,7 @@
|
||||
* i18n.js (t)
|
||||
*
|
||||
* API:
|
||||
* openModal({ title, content, onSave, onDelete, size }) → void
|
||||
* openModal({ title, content, onSave, onDelete, onClose, size }) → void
|
||||
* closeModal() → void
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@ let activeOverlay = null;
|
||||
let previouslyFocused = null;
|
||||
let focusTrapHandler = null;
|
||||
let _initialFormSnapshot = null;
|
||||
let _initialFormTimeout = null;
|
||||
let _isClosing = false;
|
||||
|
||||
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
|
||||
@@ -110,7 +111,7 @@ function serializeForm(container) {
|
||||
}
|
||||
|
||||
function isFormDirty(container) {
|
||||
if (!_initialFormSnapshot) return false;
|
||||
if (_initialFormSnapshot === null) return false;
|
||||
return serializeForm(container) !== _initialFormSnapshot;
|
||||
}
|
||||
|
||||
@@ -180,7 +181,6 @@ function _doClose(overlayEl) {
|
||||
target.remove();
|
||||
|
||||
// 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) {
|
||||
activeOverlay = null;
|
||||
|
||||
@@ -211,18 +211,15 @@ function _doClose(overlayEl) {
|
||||
* @param {string} opts.title - Titel im Modal-Header
|
||||
* @param {string} opts.content - HTML-String für den Modal-Body
|
||||
* @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 {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).
|
||||
// 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) {
|
||||
activeOverlay.removeAttribute('id');
|
||||
// force:true ensures we don't trigger another dirty check while opening a new modal
|
||||
closeModal({ force: true });
|
||||
}
|
||||
|
||||
@@ -252,6 +249,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
activeOverlay = document.getElementById('shared-modal-overlay');
|
||||
activeOverlay._onCloseCallback = onClose;
|
||||
|
||||
// Lucide-Icons rendern
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
@@ -261,8 +259,9 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
trapFocus(panel);
|
||||
|
||||
// Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden)
|
||||
if (_initialFormTimeout) clearTimeout(_initialFormTimeout);
|
||||
_initialFormSnapshot = null;
|
||||
setTimeout(() => {
|
||||
_initialFormTimeout = setTimeout(() => {
|
||||
if (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();
|
||||
});
|
||||
|
||||
// iOS PWA: click-Events auf non-interactive divs sind unzuverlässig →
|
||||
// touchend als Fallback (passive, damit Scroll nicht blockiert wird)
|
||||
// iOS PWA: touchend als Fallback
|
||||
activeOverlay.addEventListener('touchend', (e) => {
|
||||
if (e.target === activeOverlay) closeModal();
|
||||
}, { passive: true });
|
||||
@@ -288,15 +286,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
activeOverlay.querySelector('[data-action="close-modal"]')
|
||||
?.addEventListener('click', () => closeModal());
|
||||
|
||||
// Escape
|
||||
// Escape (nur einmal binden)
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
document.addEventListener('keydown', onEscape);
|
||||
|
||||
// Callback für Aufrufer (Form-Events binden etc.)
|
||||
// Callback für Aufrufer
|
||||
if (typeof onSave === 'function') onSave(panel);
|
||||
|
||||
// Loading-State: btn--loading auf Submit-Button während async-Save.
|
||||
// rAF-Check: Validierung schlägt fehl → btn bleibt enabled → Loading sofort entfernen.
|
||||
// MutationObserver: Error-Pfad → btn wird re-enabled → Loading entfernen.
|
||||
// Loading-State
|
||||
panel.addEventListener('submit', (e) => {
|
||||
const btn = e.target.querySelector('[type="submit"], .btn--primary');
|
||||
if (!btn || btn.disabled) return;
|
||||
@@ -310,7 +307,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
|
||||
});
|
||||
}, { capture: true });
|
||||
|
||||
// Standalone: Statusbar abdunkeln (Overlay-Effekt)
|
||||
// Standalone: Statusbar abdunkeln
|
||||
if (window.oikos?.setThemeColor) {
|
||||
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 } = {}) {
|
||||
// If already closing, ignore call
|
||||
if (!activeOverlay || _isClosing) return;
|
||||
_isClosing = true;
|
||||
|
||||
if (!force) {
|
||||
const panel = activeOverlay.querySelector('.modal-panel');
|
||||
if (panel && isFormDirty(panel)) {
|
||||
const dirtyOverlay = activeOverlay;
|
||||
const dirtySnapshot = _initialFormSnapshot;
|
||||
let confirmed;
|
||||
try {
|
||||
activeOverlay = null;
|
||||
_isClosing = false;
|
||||
confirmed = await confirmModal(t('modal.unsavedChanges'), {
|
||||
danger: false,
|
||||
confirmLabel: t('modal.discardChanges'),
|
||||
});
|
||||
} catch (err) {
|
||||
const overlayId = dirtyOverlay.id;
|
||||
|
||||
// Momentarily clear global state to allow confirmModal to open without deadlock
|
||||
dirtyOverlay.removeAttribute('id');
|
||||
activeOverlay = null;
|
||||
|
||||
const confirmed = await confirmModal(t('modal.unsavedChanges'), {
|
||||
danger: false,
|
||||
confirmLabel: t('modal.discardChanges'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
// Restore previous modal state if user cancelled discard
|
||||
if (overlayId) dirtyOverlay.id = overlayId;
|
||||
activeOverlay = dirtyOverlay;
|
||||
_initialFormSnapshot = dirtySnapshot;
|
||||
_isClosing = false;
|
||||
throw err;
|
||||
}
|
||||
activeOverlay = dirtyOverlay;
|
||||
_initialFormSnapshot = dirtySnapshot;
|
||||
if (!confirmed) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (window.oikos?.setThemeColor) {
|
||||
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
|
||||
}
|
||||
_isClosing = false;
|
||||
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;
|
||||
|
||||
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 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 (panel) panel.removeEventListener('keydown', focusTrapHandler);
|
||||
focusTrapHandler = null;
|
||||
@@ -376,12 +381,14 @@ export async function closeModal({ force = false } = {}) {
|
||||
panel.removeEventListener('focusin', panel._onInputFocus);
|
||||
}
|
||||
|
||||
// Sheet-Out-Animation auf Mobile, danach _doClose
|
||||
// Animation handling
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile && panel) {
|
||||
panel.classList.add('modal-panel--closing');
|
||||
// Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.)
|
||||
const fallback = setTimeout(() => { _isClosing = false; _doClose(capturedOverlay); }, 300);
|
||||
const fallback = setTimeout(() => {
|
||||
_isClosing = false;
|
||||
_doClose(capturedOverlay);
|
||||
}, 400); // Slightly longer fallback
|
||||
panel.addEventListener('animationend', () => {
|
||||
clearTimeout(fallback);
|
||||
_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 = '') {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -413,7 +412,7 @@ export function promptModal(label, defaultValue = '') {
|
||||
function finish(value) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
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>
|
||||
</div>
|
||||
</form>`,
|
||||
onClose: () => finish(null),
|
||||
onSave(panel) {
|
||||
const form = panel.querySelector('#prompt-modal-form');
|
||||
const input = panel.querySelector('#prompt-modal-input');
|
||||
@@ -443,24 +443,6 @@ export function promptModal(label, defaultValue = '') {
|
||||
|
||||
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(() => {
|
||||
input.focus();
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -488,7 +463,7 @@ export function selectModal(label, options) {
|
||||
function finish(value) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
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>
|
||||
</div>
|
||||
</form>`,
|
||||
onClose: () => finish(null),
|
||||
onSave(panel) {
|
||||
const form = panel.querySelector('#select-modal-form');
|
||||
const select = panel.querySelector('#select-modal-input');
|
||||
@@ -520,40 +496,15 @@ export function selectModal(label, options) {
|
||||
});
|
||||
|
||||
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 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -561,7 +512,7 @@ export function confirmModal(message, { confirmLabel, danger = false } = {}) {
|
||||
function finish(value) {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
closeModal();
|
||||
closeModal({ force: true });
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
@@ -575,31 +526,17 @@ export function confirmModal(message, { confirmLabel, danger = false } = {}) {
|
||||
${confirmLabel ?? t('common.confirm')}
|
||||
</button>
|
||||
</div>`,
|
||||
onClose: () => finish(false),
|
||||
onSave(panel) {
|
||||
panel.querySelector('#confirm-modal-ok')?.addEventListener('click', () => finish(true));
|
||||
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) {
|
||||
@@ -625,23 +562,12 @@ function _validateField(input) {
|
||||
return hasValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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', () => _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) {
|
||||
let firstInvalid = null;
|
||||
let allValid = true;
|
||||
@@ -656,16 +582,6 @@ export function validateAll(formContainer) {
|
||||
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) {
|
||||
btn.classList.remove('btn--loading');
|
||||
const label = originalLabel ?? btn.textContent;
|
||||
@@ -691,13 +607,6 @@ export function btnSuccess(btn, originalLabel) {
|
||||
}, 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) {
|
||||
btn.classList.add('btn--loading');
|
||||
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) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
btn.classList.add('btn--error-static');
|
||||
@@ -719,7 +623,7 @@ export function btnError(btn) {
|
||||
return;
|
||||
}
|
||||
btn.classList.remove('btn--shaking');
|
||||
void btn.offsetWidth; // Reflow für Animation-Restart
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add('btn--shaking');
|
||||
btn.addEventListener('animationend', () => btn.classList.remove('btn--shaking'), { once: true });
|
||||
}
|
||||
|
||||
@@ -402,6 +402,13 @@ function attachmentHtml(event) {
|
||||
</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) {
|
||||
if (!event?.attachment_data) return '';
|
||||
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() : '');
|
||||
|
||||
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__title">${eventIconHtml(ev.icon)}<span>${esc(ev.title)}</span></div>
|
||||
<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>` : ''}
|
||||
<div>${timeStr}</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.assigned_name ? `<div>👤 ${esc(ev.assigned_name)}</div>` : ''}
|
||||
</div>
|
||||
@@ -1084,7 +1091,7 @@ function showEventPopup(ev, anchor) {
|
||||
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
`);
|
||||
|
||||
document.body.appendChild(popup);
|
||||
if (window.lucide) lucide.createIcons();
|
||||
|
||||
+1
-1
@@ -268,7 +268,7 @@ async function runSync() {
|
||||
// Server starten
|
||||
// --------------------------------------------------------
|
||||
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'}`);
|
||||
|
||||
// Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
|
||||
|
||||
@@ -13,7 +13,7 @@ const MAX_RRULE = 300;
|
||||
// Regex-Muster
|
||||
const DATE_RE = /^\d{4}-\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 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)?)?)?$/;
|
||||
@@ -120,7 +120,15 @@ function datetime(val, field, required = false) {
|
||||
}
|
||||
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: 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user