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
|
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
@@ -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
@@ -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:
|
||||||
|
|||||||
+61
-157
@@ -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 {
|
|
||||||
activeOverlay = null;
|
// Momentarily clear global state to allow confirmModal to open without deadlock
|
||||||
_isClosing = false;
|
dirtyOverlay.removeAttribute('id');
|
||||||
confirmed = await confirmModal(t('modal.unsavedChanges'), {
|
activeOverlay = null;
|
||||||
danger: false,
|
|
||||||
confirmLabel: t('modal.discardChanges'),
|
const confirmed = await confirmModal(t('modal.unsavedChanges'), {
|
||||||
});
|
danger: false,
|
||||||
} catch (err) {
|
confirmLabel: t('modal.discardChanges'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
// Restore previous modal state if user cancelled discard
|
||||||
|
if (overlayId) dirtyOverlay.id = overlayId;
|
||||||
activeOverlay = dirtyOverlay;
|
activeOverlay = dirtyOverlay;
|
||||||
_initialFormSnapshot = dirtySnapshot;
|
_initialFormSnapshot = dirtySnapshot;
|
||||||
_isClosing = false;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
activeOverlay = dirtyOverlay;
|
|
||||||
_initialFormSnapshot = dirtySnapshot;
|
|
||||||
if (!confirmed) {
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user