From 4aa9bc2a486d1f7a6ba87bcfa56d6e6836919ca7 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 22:44:25 -0300 Subject: [PATCH] fix(calendar): tighten modal and attachment rendering --- public/index.html | 24 ++---- public/pages/calendar.js | 163 ++++++++++++++++++++++++++----------- public/styles/calendar.css | 61 ++++++++++++++ public/styles/layout.css | 14 ++-- public/theme-init.js | 10 +++ server/index.js | 8 +- server/routes/calendar.js | 26 ++++-- 7 files changed, 224 insertions(+), 82 deletions(-) create mode 100644 public/theme-init.js diff --git a/public/index.html b/public/index.html index 600026f..dcc37e3 100644 --- a/public/index.html +++ b/public/index.html @@ -31,6 +31,11 @@ + + + @@ -39,30 +44,11 @@ - - - - Zum Hauptinhalt springen - `; } return ` - + ${name} `; } +function attachmentPreviewHtml(event) { + if (!event?.attachment_data) return ''; + const name = esc(event.attachment_name || t('calendar.attachmentFallback')); + const src = esc(attachmentDataUrl(event.attachment_data, event.attachment_mime)); + return isImageAttachment(event.attachment_mime) + ? `${name}` + : `${name}`; +} + +function selectedAttachmentLabel(name) { + return t('documents.selectedFileLabel', { name: name || t('calendar.attachmentFallback') }); +} + function bindDateInputs(root) { root.querySelectorAll('.js-date-input').forEach((input) => { input.addEventListener('blur', () => { @@ -1028,12 +1049,24 @@ function showEventPopup(ev, anchor) { popup.querySelector('.event-popup__actions').before(resetLink); } - // Positionierung + // Positionierung: erst messen, dann im Viewport halten. const rect = anchor.getBoundingClientRect(); - const top = Math.min(rect.bottom + 8, window.innerHeight - 280); - const left = Math.min(rect.left, window.innerWidth - 340); - popup.style.top = `${Math.max(8, top)}px`; - popup.style.left = `${Math.max(8, left)}px`; + const gap = 8; + const margin = 8; + const popupRect = popup.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const fitsBelow = rect.bottom + gap + popupRect.height <= viewportHeight - margin; + const top = fitsBelow + ? rect.bottom + gap + : Math.max(margin, rect.top - gap - popupRect.height); + const left = Math.min( + Math.max(margin, rect.left), + Math.max(margin, viewportWidth - popupRect.width - margin) + ); + const maxTop = Math.max(margin, viewportHeight - popupRect.height - margin); + popup.style.top = `${Math.min(Math.max(margin, top), maxTop)}px`; + popup.style.left = `${left}px`; popup.querySelector('#popup-edit').addEventListener('click', async () => { popup.remove(); @@ -1257,6 +1290,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { const reminderOffset = panel.querySelector('#modal-reminder-offset'); const reminderCustom = panel.querySelector('#modal-reminder-custom'); const attachmentInput = panel.querySelector('#modal-attachment'); + const selectedAttachment = panel.querySelector('#modal-selected-attachment'); const attachmentPreview = panel.querySelector('#modal-attachment-preview'); const attachmentState = { name: event?.attachment_name || null, @@ -1264,39 +1298,55 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { size: event?.attachment_size || null, data: event?.attachment_data || null, }; - const syncAttachmentPreview = () => { - if (!attachmentPreview) return; - attachmentPreview.replaceChildren(); - if (!attachmentState.data) { - attachmentPreview.hidden = true; - return; - } - attachmentPreview.hidden = false; - if (isImageAttachment(attachmentState.mime)) { - attachmentPreview.insertAdjacentHTML('beforeend', `${esc(attachmentState.name || '')}`); - } else { - attachmentPreview.insertAdjacentHTML('beforeend', `${esc(attachmentState.name || '')}`); - } + + const syncSelectedAttachment = () => { + if (!selectedAttachment) return; + selectedAttachment.hidden = !attachmentState.name; + selectedAttachment.textContent = attachmentState.name ? selectedAttachmentLabel(attachmentState.name) : ''; }; - attachmentInput?.addEventListener('change', async () => { + + const syncAttachmentSelection = () => { + if (!selectedAttachment) return; const file = attachmentInput.files?.[0]; - if (!file) return; - if (file.size > MAX_ATTACHMENT_BYTES) { - window.oikos?.showToast(t('calendar.attachmentTooLarge'), 'error'); - attachmentInput.value = ''; + if (file) { + selectedAttachment.hidden = false; + selectedAttachment.textContent = selectedAttachmentLabel(file.name); + if (attachmentPreview) { + attachmentPreview.replaceChildren(); + attachmentPreview.hidden = true; + } return; } - try { - attachmentState.data = await readFileAsDataUrl(file); - attachmentState.name = file.name; - attachmentState.mime = file.type || 'application/octet-stream'; - attachmentState.size = file.size; - syncAttachmentPreview(); - } catch (err) { - window.oikos?.showToast(err.message, 'danger'); - } - }); - syncAttachmentPreview(); + syncSelectedAttachment(); + }; + + attachmentInput?.addEventListener('change', syncAttachmentSelection); + + const attachmentDropzone = panel.querySelector('#modal-attachment-dropzone'); + if (attachmentDropzone && attachmentInput) { + ['dragenter', 'dragover'].forEach((eventName) => { + attachmentDropzone.addEventListener(eventName, (dropEvent) => { + dropEvent.preventDefault(); + attachmentDropzone.classList.add('document-dropzone--active'); + }); + }); + ['dragleave', 'drop'].forEach((eventName) => { + attachmentDropzone.addEventListener(eventName, (dropEvent) => { + dropEvent.preventDefault(); + attachmentDropzone.classList.remove('document-dropzone--active'); + }); + }); + attachmentDropzone.addEventListener('drop', (dropEvent) => { + const file = dropEvent.dataTransfer?.files?.[0]; + if (!file) return; + const transfer = new DataTransfer(); + transfer.items.add(file); + attachmentInput.files = transfer.files; + syncAttachmentSelection(); + }); + } + + syncSelectedAttachment(); reminderOffset?.addEventListener('change', () => { if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom'; }); @@ -1445,14 +1495,20 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
- +
${t('calendar.attachmentHint')}
@@ -1524,15 +1580,30 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create'); return; } + const attachmentPayload = { + name: attachmentState?.name || null, + mime: attachmentState?.mime || null, + size: attachmentState?.size || null, + data: attachmentState?.data || null, + }; + const attachmentFile = overlay.querySelector('#modal-attachment')?.files?.[0]; + if (attachmentFile) { + if (attachmentFile.size > MAX_ATTACHMENT_BYTES) throw new Error(t('calendar.attachmentTooLarge')); + attachmentPayload.name = attachmentFile.name; + attachmentPayload.mime = attachmentFile.type || 'application/octet-stream'; + attachmentPayload.size = attachmentFile.size; + attachmentPayload.data = await readFileAsDataUrl(attachmentFile); + } + const body = { title, description, start_datetime, end_datetime, all_day: allday ? 1 : 0, location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null, recurrence_rule: rrule.recurrence_rule, - attachment_name: attachmentState?.name || null, - attachment_mime: attachmentState?.mime || null, - attachment_size: attachmentState?.size || null, - attachment_data: attachmentState?.data || null, + attachment_name: attachmentPayload.name, + attachment_mime: attachmentPayload.mime, + attachment_size: attachmentPayload.size, + attachment_data: attachmentPayload.data, }; let savedEventId = eventId; diff --git a/public/styles/calendar.css b/public/styles/calendar.css index 6782923..ed17b89 100644 --- a/public/styles/calendar.css +++ b/public/styles/calendar.css @@ -445,6 +445,67 @@ word-break: break-word; } +.document-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-2); + min-height: 148px; + padding: var(--space-5); + border: 1.5px dashed color-mix(in srgb, var(--module-calendar) 48%, var(--color-border)); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--module-calendar) 7%, var(--color-surface)); + color: var(--color-text-secondary); + text-align: center; + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast); +} + +.document-dropzone:hover, +.document-dropzone--active { + border-color: var(--module-calendar); + background: color-mix(in srgb, var(--module-calendar) 12%, var(--color-surface)); +} + +.document-dropzone--active { + transform: translateY(-1px); +} + +.document-dropzone__icon { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + color: var(--module-calendar); + background: color-mix(in srgb, var(--module-calendar) 15%, transparent); +} + +.document-dropzone__icon svg { + width: 22px; + height: 22px; +} + +.document-dropzone__title { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); +} + +.document-dropzone__hint, +.document-dropzone__file { + max-width: 100%; + color: var(--color-text-tertiary); + font-size: var(--text-xs); + overflow-wrap: anywhere; +} + +.document-dropzone__file { + color: var(--module-calendar); + font-weight: var(--font-weight-medium); +} + /* -------------------------------------------------------- * Tagesansicht * -------------------------------------------------------- */ diff --git a/public/styles/layout.css b/public/styles/layout.css index 1947d5d..0cf0b26 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -922,9 +922,13 @@ background-color: var(--color-overlay); z-index: var(--z-modal); display: flex; - align-items: flex-end; + align-items: center; justify-content: center; overflow: hidden; + padding: max(var(--space-3), env(safe-area-inset-top)) + max(var(--space-3), env(safe-area-inset-right)) + max(var(--space-3), env(safe-area-inset-bottom)) + max(var(--space-3), env(safe-area-inset-left)); animation: modal-overlay-in 0.2s ease forwards; /* iOS Safari: click-Events auf non-interactive divs erfordern cursor:pointer */ cursor: pointer; @@ -932,7 +936,6 @@ @media (min-width: 768px) { .modal-overlay { - align-items: center; padding: var(--space-6); } } @@ -940,11 +943,11 @@ .modal-panel { background-color: var(--color-surface); width: 100%; - max-height: 90dvh; + max-height: calc(100dvh - (2 * var(--space-3)) - env(safe-area-inset-top) - env(safe-area-inset-bottom)); overflow: hidden; display: flex; flex-direction: column; - border-radius: var(--radius-lg) var(--radius-lg) 0 0; + border-radius: var(--radius-lg); border: 1px solid var(--color-border-subtle); box-shadow: var(--shadow-lg); animation: modal-slide-up 0.25s var(--ease-out) forwards; @@ -955,6 +958,7 @@ @media (min-width: 768px) { .modal-panel { max-width: 520px; + max-height: calc(100dvh - (2 * var(--space-6))); border-radius: var(--radius-lg); animation: modal-scale-in 0.2s var(--ease-out) forwards; } @@ -1106,7 +1110,7 @@ @keyframes sheet-out { from { transform: translateY(0); } - to { transform: translateY(100%); } + to { transform: translateY(16px); opacity: 0; } } @media (prefers-reduced-motion: reduce) { diff --git a/public/theme-init.js b/public/theme-init.js new file mode 100644 index 0000000..9bfb2d2 --- /dev/null +++ b/public/theme-init.js @@ -0,0 +1,10 @@ +(function() { + var stored = localStorage.getItem('oikos-theme'); + if (stored === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else if (stored === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } else { + document.documentElement.removeAttribute('data-theme'); + } +})(); diff --git a/server/index.js b/server/index.js index 5050633..1a27184 100644 --- a/server/index.js +++ b/server/index.js @@ -55,13 +55,7 @@ app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - scriptSrc: [ - "'self'", - // Inline-Script: Theme-Detection (Flash-Prevention) - "'sha256-vqqBNo1oitnzIntwkG83UaYqkUAnV/oZ/RkvcA41Y6A='", - // Alpine.js CDN (optional, falls verwendet) - 'https://cdn.jsdelivr.net', - ], + scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", 'data:'], connectSrc: ["'self'"], diff --git a/server/routes/calendar.js b/server/routes/calendar.js index af59d31..e966ef5 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -86,6 +86,21 @@ function parseAttachment(dataUrl) { return { name: null, mime, size: buffer.length, data: base64 }; } +function attachmentDataUrl(event) { + if (!event?.attachment_data) return event?.attachment_data ?? null; + if (String(event.attachment_data).startsWith('data:')) return event.attachment_data; + if (!event.attachment_mime) return event.attachment_data; + return `data:${event.attachment_mime};base64,${event.attachment_data}`; +} + +function serializeEvent(event) { + if (!event) return event; + return { + ...event, + attachment_data: attachmentDataUrl(event), + }; +} + // -------------------------------------------------------- // RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events // innerhalb [from, to] generieren (inklusive beider Grenzen). @@ -225,7 +240,7 @@ router.get('/', (req, res) => { sql += ' ORDER BY e.start_datetime ASC, e.all_day DESC'; const rawEvents = db.get().prepare(sql).all(...params); - const events = expandRecurringEvents(rawEvents, from, to); + const events = expandRecurringEvents(rawEvents, from, to).map(serializeEvent); res.json({ data: events, from, to }); } catch (err) { log.error('', err); @@ -271,7 +286,8 @@ router.get('/upcoming', (req, res) => { const expanded = expandRecurringEvents(rawEvents, nowDate, future) .filter((e) => e.start_datetime >= new Date().toISOString()) - .slice(0, limit); + .slice(0, limit) + .map(serializeEvent); res.json({ data: expanded }); } catch (err) { @@ -571,7 +587,7 @@ router.get('/:id', (req, res) => { `).get(id); if (!event) return res.status(404).json({ error: 'Termin nicht gefunden', code: 404 }); - res.json({ data: event }); + res.json({ data: serializeEvent(event) }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 }); @@ -649,7 +665,7 @@ router.post('/', (req, res) => { WHERE e.id = ? `).get(result.lastInsertRowid); - res.status(201).json({ data: event }); + res.status(201).json({ data: serializeEvent(event) }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 }); @@ -743,7 +759,7 @@ router.put('/:id', (req, res) => { WHERE e.id = ? `).get(id); - res.json({ data: updated }); + res.json({ data: serializeEvent(updated) }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 });