From 3e0549524e719a8b0477ff6ac43ed95d56f2adec Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 07:09:37 -0300 Subject: [PATCH 1/4] fix(birthdays): match profile picture editor pattern --- public/pages/birthdays.js | 54 ++++++++++++++++--------------- public/styles/birthdays.css | 63 ++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index 9d04eb7..70e8c79 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -275,23 +275,30 @@ function openBirthdayModal({ mode, birthday = null }) { title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'), content: `
-
${birthdayPreviewHtml(birthday?.name || '', photoData)}
-
- - -
-
- - -
-
- - -
${t('birthdays.photoOptional')}
-
- +
+ +
+
+ + +
+
+ + +
+ +
+ + +
@@ -310,18 +317,15 @@ function openBirthdayModal({ mode, birthday = null }) { onSave(panel) { const nameInput = panel.querySelector('#bd-name'); const preview = panel.querySelector('#birthday-preview'); + const fileInput = panel.querySelector('#bd-photo'); + const photoEdit = panel.querySelector('#bd-photo-edit'); const renderPreview = () => { - preview.replaceChildren(); - preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData)); + preview.innerHTML = birthdayPreviewHtml(nameInput.value.trim(), photoData); }; nameInput.addEventListener('input', renderPreview); - panel.querySelectorAll('.js-date-input').forEach((input) => { - input.addEventListener('blur', () => { - const parsed = parseDateInput(input.value); - if (parsed) input.value = formatDateInput(parsed); - }); - }); - panel.querySelector('#bd-photo').addEventListener('change', async (e) => { + preview.addEventListener('click', () => fileInput?.click()); + photoEdit?.addEventListener('click', () => fileInput?.click()); + fileInput?.addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; try { @@ -333,7 +337,7 @@ function openBirthdayModal({ mode, birthday = null }) { }); panel.querySelector('#bd-remove-photo').addEventListener('click', () => { photoData = null; - panel.querySelector('#bd-photo').value = ''; + if (fileInput) fileInput.value = ''; renderPreview(); }); panel.querySelector('#bd-cancel').addEventListener('click', closeModal); diff --git a/public/styles/birthdays.css b/public/styles/birthdays.css index fec9324..05c48e7 100644 --- a/public/styles/birthdays.css +++ b/public/styles/birthdays.css @@ -287,6 +287,21 @@ background: color-mix(in srgb, var(--module-accent) 16%, white); } +.birthday-avatar-editor { + width: 84px; + height: 84px; + margin: 0 auto var(--space-3); + padding: 0; + border: none; + border-radius: var(--radius-full); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--module-accent) 16%, white); + cursor: pointer; +} + .birthday-preview__image { width: 100%; height: 100%; @@ -300,7 +315,43 @@ } .birthday-modal__photo-actions { - margin-top: var(--space-2); + display: flex; + gap: var(--space-2); + justify-content: center; + margin-top: calc(var(--space-1) * -1); + margin-bottom: var(--space-2); +} + +.birthday-modal__photo-action { + width: 30px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); +} + +.birthday-modal__photo-action svg { + width: 14px; + height: 14px; +} + +.birthday-modal__photo-action--danger { + color: var(--color-danger); +} + +.birthday-modal__identity { + display: flex; + align-items: flex-start; + gap: var(--space-4); +} + +.birthday-modal__fields { + flex: 1; + min-width: 0; } .birthday-modal__hint { @@ -308,6 +359,16 @@ font-size: var(--text-sm); } +@media (max-width: 640px) { + .birthday-modal__identity { + flex-direction: column; + } + + .birthday-modal__photo-actions { + justify-content: flex-start; + } +} + @media (max-width: 960px) { .birthdays-grid { grid-template-columns: 1fr; From 36913faf8dd880985885ab085145bf44f4e283eb Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 07:11:34 -0300 Subject: [PATCH 2/4] fix(birthdays): anchor photo action buttons under avatar --- public/pages/birthdays.js | 26 ++++++++++++++------------ public/styles/birthdays.css | 8 ++++++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index 70e8c79..cbb2f23 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -276,9 +276,20 @@ function openBirthdayModal({ mode, birthday = null }) { content: `
- +
+ + +
+ + +
+
@@ -290,15 +301,6 @@ function openBirthdayModal({ mode, birthday = null }) {
- -
- - -
diff --git a/public/styles/birthdays.css b/public/styles/birthdays.css index 05c48e7..ed08ea5 100644 --- a/public/styles/birthdays.css +++ b/public/styles/birthdays.css @@ -349,6 +349,14 @@ gap: var(--space-4); } +.birthday-modal__photo-wrap { + width: 84px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; +} + .birthday-modal__fields { flex: 1; min-width: 0; From d9218a49410931066acc2c67feec14c0e9461939 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Wed, 29 Apr 2026 08:53:07 -0300 Subject: [PATCH 3/4] feat(calendar): add overlapping layout and event attachments --- public/locales/ar.json | 7 +- public/locales/de.json | 7 +- public/locales/el.json | 7 +- public/locales/en.json | 7 +- public/locales/es.json | 7 +- public/locales/fr.json | 7 +- public/locales/hi.json | 7 +- public/locales/it.json | 7 +- public/locales/ja.json | 7 +- public/locales/pt.json | 7 +- public/locales/ru.json | 7 +- public/locales/sv.json | 7 +- public/locales/tr.json | 7 +- public/locales/uk.json | 7 +- public/locales/zh.json | 7 +- public/pages/calendar.js | 171 ++++++++++++++++++++++++++++++++++--- public/styles/calendar.css | 44 +++++++++- server/db-schema-test.js | 6 ++ server/db.js | 10 +++ server/openapi.js | 17 +++- server/routes/calendar.js | 57 ++++++++++++- 21 files changed, 377 insertions(+), 33 deletions(-) diff --git a/public/locales/ar.json b/public/locales/ar.json index 5cfc2bb..f342dae 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -358,7 +358,12 @@ "resetToast": "تم إعادة تعيين التغييرات." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "لوحة الملاحظات", diff --git a/public/locales/de.json b/public/locales/de.json index 748623b..8644c62 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -375,7 +375,12 @@ "resetToast": "Änderungen zurückgesetzt." }, "iconLabel": "Icon", - "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden." + "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Notizen", diff --git a/public/locales/el.json b/public/locales/el.json index c26e383..b1c39e6 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -358,7 +358,12 @@ "resetToast": "Οι αλλαγές επαναφέρθηκαν." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Σημειώσεις", diff --git a/public/locales/en.json b/public/locales/en.json index 77a4869..c9c5d11 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -358,7 +358,12 @@ "resetToast": "Changes reset." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Board", diff --git a/public/locales/es.json b/public/locales/es.json index 7ecee87..4e6716a 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -358,7 +358,12 @@ "resetToast": "Cambios restablecidos." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Notas", diff --git a/public/locales/fr.json b/public/locales/fr.json index 12feca1..82cf675 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -358,7 +358,12 @@ "resetToast": "Modifications annulées." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Notes", diff --git a/public/locales/hi.json b/public/locales/hi.json index 30069a2..0f2c858 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -358,7 +358,12 @@ "resetToast": "परिवर्तन रीसेट हो गए।" }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "नोट बोर्ड", diff --git a/public/locales/it.json b/public/locales/it.json index c5df136..9e085c7 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -358,7 +358,12 @@ "resetToast": "Modifiche ripristinate." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Bacheca", diff --git a/public/locales/ja.json b/public/locales/ja.json index 74b90e4..7995b14 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -358,7 +358,12 @@ "resetToast": "変更がリセットされました。" }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "メモボード", diff --git a/public/locales/pt.json b/public/locales/pt.json index 4c6de1f..d74decc 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -358,7 +358,12 @@ "resetToast": "Alterações restauradas." }, "iconLabel": "Ícone", - "invalidDate": "Use uma data válida no formato selecionado." + "invalidDate": "Use uma data válida no formato selecionado.", + "attachmentLabel": "Anexo", + "attachmentHint": "Anexe uma imagem, PDF ou documento local. Imagens aparecem no pop-up do evento.", + "attachmentFallback": "Anexo", + "attachmentReadError": "Nao foi possivel ler o anexo.", + "attachmentTooLarge": "O anexo pode ter no maximo 5 MB." }, "notes": { "title": "Quadro de notas", diff --git a/public/locales/ru.json b/public/locales/ru.json index 6d7b270..4ad7293 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -358,7 +358,12 @@ "resetToast": "Изменения сброшены." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Заметки", diff --git a/public/locales/sv.json b/public/locales/sv.json index 5cd0ec7..fb700de 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -358,7 +358,12 @@ "resetToast": "Ändringar återställda." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Anteckningar", diff --git a/public/locales/tr.json b/public/locales/tr.json index ceb5e0a..bfa2345 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -358,7 +358,12 @@ "resetToast": "Değişiklikler sıfırlandı." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Notlar", diff --git a/public/locales/uk.json b/public/locales/uk.json index ded1608..f27b19e 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -358,7 +358,12 @@ "resetToast": "Зміни скинуто." }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "Нотатки", diff --git a/public/locales/zh.json b/public/locales/zh.json index 9567d54..fdbbafb 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -358,7 +358,12 @@ "resetToast": "更改已重置。" }, "iconLabel": "Icon", - "invalidDate": "Use a valid date in the selected date format." + "invalidDate": "Use a valid date in the selected date format.", + "attachmentLabel": "Attachment", + "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", + "attachmentFallback": "Attachment", + "attachmentReadError": "The attachment could not be read.", + "attachmentTooLarge": "Attachment may be at most 5 MB." }, "notes": { "title": "便签板", diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 20dc2c9..f521330 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -169,6 +169,8 @@ const EVENT_ICONS = [ ]; const CUSTOM_EVENT_ICONS = new Set(['tooth']); +const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; +const ATTACHMENT_IMAGE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht @@ -312,6 +314,35 @@ function eventIconElement(icon, className = 'event-icon') { return el; } +function isImageAttachment(mime) { + return ATTACHMENT_IMAGE_MIME.has(String(mime || '').toLowerCase()); +} + +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error(t('calendar.attachmentReadError'))); + reader.readAsDataURL(file); + }); +} + +function attachmentHtml(event) { + if (!event?.attachment_data) return ''; + const name = esc(event.attachment_name || t('calendar.attachmentFallback')); + if (isImageAttachment(event.attachment_mime)) { + return ` +
+ ${name} +
`; + } + return ` + + + ${name} + `; +} + function bindDateInputs(root) { root.querySelectorAll('.js-date-input').forEach((input) => { input.addEventListener('blur', () => { @@ -631,6 +662,7 @@ function renderWeekView(container) { const timedEvs = days.map((d) => eventsOnDay(d).filter((e) => !e.all_day && e.start_datetime.includes('T')) ); + const layouts = timedEvs.map((events) => layoutOverlaps(events)); container.innerHTML = `
@@ -674,7 +706,7 @@ function renderWeekView(container) { ${Array.from({ length: 24 }, (_, h) => `
`).join('')} - ${timedEvs[i].map((ev) => renderWeekEvent(ev)).join('')} + ${timedEvs[i].map((ev) => renderWeekEvent(ev, layouts[i].get(ev.id))).join('')} ${d === state.today ? `
` : ''}
`).join('')} @@ -712,19 +744,18 @@ function renderWeekView(container) { } } -function renderWeekEvent(ev) { - const start = timeToMinutes(localTime(ev.start_datetime)); - const end = ev.end_datetime - ? timeToMinutes(localTime(ev.end_datetime)) - : start + 60; +function renderWeekEvent(ev, layout = null) { + const { start, end } = timeRangeForEvent(ev); const duration = Math.max(end - start, 30); const top = (start / 60) * HOUR_HEIGHT; const height = (duration / 60) * HOUR_HEIGHT - 2; + const left = layout ? `calc(${(layout.colIndex / layout.totalCols) * 100}% + 2px)` : '2px'; + const width = layout ? `calc(${100 / layout.totalCols}% - 4px)` : 'auto'; return `
+ style="top:${top}px;height:${height}px;left:${left};width:${width};${ev.cal_color || ev.color ? `background-color:${esc(ev.cal_color || ev.color)};` : ''}${getContrastColor(ev.cal_color || ev.color) ? `color:${getContrastColor(ev.cal_color || ev.color)};` : ''}">
${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}${esc(ev.title)}
${formatTime(ev.start_datetime)}${ev.end_datetime ? '–' + formatTime(ev.end_datetime) : ''}
@@ -743,6 +774,66 @@ function nowTop() { return (minutes / 60) * HOUR_HEIGHT; } +function timeRangeForEvent(ev) { + const start = timeToMinutes(localTime(ev.start_datetime)); + const end = ev.end_datetime + ? timeToMinutes(localTime(ev.end_datetime)) + : start + 60; + return { + start, + end: Math.max(end, start + 30), + }; +} + +function layoutOverlaps(events) { + const groups = []; + const sorted = [...events].sort((a, b) => { + const aRange = timeRangeForEvent(a); + const bRange = timeRangeForEvent(b); + return aRange.start - bRange.start || aRange.end - bRange.end; + }); + + let current = []; + let currentEnd = -1; + for (const ev of sorted) { + const range = timeRangeForEvent(ev); + if (!current.length || range.start < currentEnd) { + current.push(ev); + currentEnd = current.length === 1 ? range.end : Math.max(currentEnd, range.end); + } else { + groups.push(current); + current = [ev]; + currentEnd = range.end; + } + } + if (current.length) groups.push(current); + + const layout = new Map(); + for (const group of groups) { + const columns = []; + const placements = []; + for (const ev of group) { + const range = timeRangeForEvent(ev); + let colIndex = columns.findIndex((end) => end <= range.start); + if (colIndex === -1) { + colIndex = columns.length; + columns.push(range.end); + } else { + columns[colIndex] = range.end; + } + placements.push({ ev, colIndex }); + } + const totalCols = Math.max(columns.length, 1); + for (const placement of placements) { + layout.set(placement.ev.id, { + colIndex: placement.colIndex, + totalCols, + }); + } + } + return layout; +} + // -------------------------------------------------------- // Tagesansicht // -------------------------------------------------------- @@ -752,6 +843,7 @@ function renderDayView(container) { const dayEvs = eventsOnDay(state.cursor); const allday = dayEvs.filter((e) => e.all_day || !e.start_datetime.includes('T')); const timed = dayEvs.filter((e) => !e.all_day && e.start_datetime.includes('T')); + const layout = layoutOverlaps(timed); container.innerHTML = `
@@ -781,7 +873,7 @@ function renderDayView(container) { ${Array.from({ length: 24 }, (_, h) => `
`).join('')} - ${timed.map((ev) => renderWeekEvent(ev)).join('')} + ${timed.map((ev) => renderWeekEvent(ev, layout.get(ev.id))).join('')} ${state.cursor === state.today ? `
` : ''}
@@ -902,6 +994,7 @@ function showEventPopup(ev, anchor) {
${timeStr}
${ev.location ? `
📍 ${esc(fmtLocation(ev.location))}
` : ''} ${ev.description ? `
${esc(ev.description)}
` : ''} + ${ev.attachment_data ? attachmentHtml(ev) : ''} ${ev.assigned_name ? `
👤 ${esc(ev.assigned_name)}
` : ''}
@@ -1163,6 +1256,47 @@ 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 attachmentPreview = panel.querySelector('#modal-attachment-preview'); + const attachmentState = { + name: event?.attachment_name || null, + mime: event?.attachment_mime || null, + size: event?.attachment_size || null, + data: event?.attachment_data || null, + }; + const syncAttachmentPreview = () => { + if (!attachmentPreview) return; + attachmentPreview.innerHTML = ''; + if (!attachmentState.data) { + attachmentPreview.hidden = true; + return; + } + attachmentPreview.hidden = false; + if (isImageAttachment(attachmentState.mime)) { + attachmentPreview.innerHTML = `${esc(attachmentState.name || '')}`; + } else { + attachmentPreview.innerHTML = `${esc(attachmentState.name || '')}`; + } + }; + attachmentInput?.addEventListener('change', async () => { + const file = attachmentInput.files?.[0]; + if (!file) return; + if (file.size > MAX_ATTACHMENT_BYTES) { + window.oikos?.showToast(t('calendar.attachmentTooLarge'), 'error'); + attachmentInput.value = ''; + 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(); reminderOffset?.addEventListener('change', () => { if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom'; }); @@ -1174,7 +1308,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { await deleteEvent(event.id); }); - panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id, reminder)); + panel.querySelector('#modal-save').addEventListener('click', () => saveEvent(panel, mode, event?.id, reminder, attachmentState)); if (window.lucide) lucide.createIcons(); }, }); @@ -1309,6 +1443,19 @@ function buildEventModalContent({ mode, event, date, reminder = null }) { placeholder="${t('calendar.descriptionPlaceholder')}">${esc(isEdit && event.description ? event.description : '')}
+
+ + +
${t('calendar.attachmentHint')}
+ +
+ ${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)} ${renderCalendarReminderSection(reminder, event)} @@ -1324,7 +1471,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
`; } -async function saveEvent(overlay, mode, eventId, existingReminder = null) { +async function saveEvent(overlay, mode, eventId, existingReminder = null, attachmentState = null) { const saveBtn = overlay.querySelector('#modal-save'); const title = overlay.querySelector('#modal-title').value.trim(); @@ -1382,6 +1529,10 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) { 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, }; let savedEventId = eventId; diff --git a/public/styles/calendar.css b/public/styles/calendar.css index 2cddff7..0dfb62b 100644 --- a/public/styles/calendar.css +++ b/public/styles/calendar.css @@ -386,8 +386,6 @@ .week-event { position: absolute; - left: 2px; - right: 2px; border-radius: var(--radius-xs); padding: var(--space-0h) var(--space-1); font-size: var(--text-xs); @@ -419,6 +417,29 @@ opacity: 0.85; } +.event-attachment-preview { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.event-attachment-preview img { + width: 100%; + max-height: 180px; + object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-subtle); +} + +.event-attachment-preview a { + display: inline-flex; + align-items: center; + gap: var(--space-2); + color: var(--color-accent); + word-break: break-word; +} + /* -------------------------------------------------------- * Tagesansicht * -------------------------------------------------------- */ @@ -763,6 +784,25 @@ border-top: 1px solid var(--color-border); } +.event-popup__attachment { + margin-top: var(--space-2); +} + +.event-popup__attachment--image img { + width: 100%; + max-height: 220px; + object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--color-border-subtle); +} + +.event-popup__attachment--file { + display: inline-flex; + align-items: center; + gap: var(--space-2); + color: var(--color-accent); +} + /* -------------------------------------------------------- * Ganztägige Ereignisse (oben in Wochen-/Tagesansicht) * -------------------------------------------------------- */ diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 880b44b..e3fb25e 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -419,6 +419,12 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_family_documents_created_by ON family_documents(created_by); CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id); `, + 20: ` + ALTER TABLE calendar_events ADD COLUMN attachment_name TEXT; + ALTER TABLE calendar_events ADD COLUMN attachment_mime TEXT; + ALTER TABLE calendar_events ADD COLUMN attachment_size INTEGER; + ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT; + `, }; export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index 85892e0..3c407f9 100644 --- a/server/db.js +++ b/server/db.js @@ -853,6 +853,16 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_family_document_access_user ON family_document_access(user_id); `, }, + { + version: 27, + description: 'Calendar event attachments', + up: ` + ALTER TABLE calendar_events ADD COLUMN attachment_name TEXT; + ALTER TABLE calendar_events ADD COLUMN attachment_mime TEXT; + ALTER TABLE calendar_events ADD COLUMN attachment_size INTEGER; + ALTER TABLE calendar_events ADD COLUMN attachment_data TEXT; + `, + }, ]; /** diff --git a/server/openapi.js b/server/openapi.js index 8c45408..2d9b49e 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -349,7 +349,13 @@ function buildPaths() { }, '/api/v1/calendar': { get: op({ summary: 'List calendar events', tag: 'Calendar' }), - post: op({ summary: 'Create calendar event', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }), + post: op({ + summary: 'Create calendar event', + tag: 'Calendar', + stateChanging: true, + description: 'Supports optional local file attachments via `attachment_name`, `attachment_mime`, `attachment_size`, and `attachment_data` (base64 data URL).', + requestBody: jsonBody(null), + }), }, '/api/v1/calendar/upcoming': { get: op({ summary: 'List upcoming events', tag: 'Calendar' }) }, '/api/v1/calendar/google/auth': { get: op({ summary: 'Start Google Calendar OAuth', tag: 'Calendar', admin: true }) }, @@ -374,7 +380,14 @@ function buildPaths() { }, '/api/v1/calendar/{id}': { get: op({ summary: 'Get calendar event', tag: 'Calendar', params: [idParam()] }), - put: op({ summary: 'Update calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + put: op({ + summary: 'Update calendar event', + tag: 'Calendar', + params: [idParam()], + stateChanging: true, + description: 'Supports optional local file attachments via `attachment_name`, `attachment_mime`, `attachment_size`, and `attachment_data` (base64 data URL).', + requestBody: jsonBody(null), + }), delete: op({ summary: 'Delete calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true }), }, '/api/v1/calendar/{id}/reset': { diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 040f2a7..af59d31 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -20,6 +20,19 @@ const log = createLogger('Calendar'); const router = express.Router(); const VALID_SOURCES = ['local', 'google', 'apple', 'ics']; +const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; +const ATTACHMENT_MIME = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +]); const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/; const VALID_EVENT_ICONS = new Set([ 'calendar', 'tooth', 'drill', 'alarm-clock', 'clock', 'bell', 'map-pin', 'home', @@ -59,6 +72,20 @@ function eventIcon(value) { return VALID_EVENT_ICONS.has(icon) ? icon : null; } +function parseAttachment(dataUrl) { + const raw = typeof dataUrl === 'string' ? dataUrl.trim() : ''; + if (!raw) return { name: null, mime: null, size: null, data: null }; + const match = raw.match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/); + if (!match) throw new Error('attachment_data: ungültiges Dateiformat.'); + const mime = match[1].toLowerCase(); + if (!ATTACHMENT_MIME.has(mime)) throw new Error('attachment_data: Dateityp nicht erlaubt.'); + const base64 = match[2].replace(/\s/g, ''); + const buffer = Buffer.from(base64, 'base64'); + if (!buffer.length) throw new Error('attachment_data: Datei ist leer.'); + if (buffer.length > MAX_ATTACHMENT_BYTES) throw new Error('attachment_data: Datei darf höchstens 5 MB groß sein.'); + return { name: null, mime, size: buffer.length, data: base64 }; +} + // -------------------------------------------------------- // RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events // innerhalb [from, to] generieren (inklusive beider Grenzen). @@ -591,17 +618,24 @@ router.post('/', (req, res) => { if (!user) return res.status(400).json({ error: 'assigned_to: Benutzer nicht gefunden', code: 400 }); } + const attachment = req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null }; + const result = db.get().prepare(` INSERT INTO calendar_events (title, description, start_datetime, end_datetime, all_day, - location, color, icon, assigned_to, created_by, recurrence_rule) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + location, color, icon, assigned_to, created_by, recurrence_rule, + attachment_name, attachment_mime, attachment_size, attachment_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( vTitle.value, vDesc.value, vStart.value, vEnd.value, all_day ? 1 : 0, vLoc.value, vColor.value, vIcon, assigned_to || null, - userId, vRrule.value + userId, vRrule.value, + req.body.attachment_name || null, + attachment.mime, + attachment.size, + attachment.data ); const event = db.get().prepare(` @@ -646,10 +680,17 @@ router.put('/:id', (req, res) => { if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const vIcon = req.body.icon !== undefined ? eventIcon(req.body.icon) : event.icon; if (!vIcon) return res.status(400).json({ error: 'icon: invalid calendar event icon.', code: 400 }); + const attachment = req.body.attachment_data !== undefined + ? (req.body.attachment_data ? parseAttachment(req.body.attachment_data) : { mime: null, size: null, data: null }) + : { + mime: event.attachment_mime, + size: event.attachment_size, + data: event.attachment_data, + }; const { title, description, start_datetime, end_datetime, - all_day, location, color: colorVal, assigned_to, recurrence_rule, + all_day, location, color: colorVal, assigned_to, recurrence_rule, attachment_name, } = req.body; const userModified = event.external_source !== 'local' ? 1 : event.user_modified; @@ -666,6 +707,10 @@ router.put('/:id', (req, res) => { icon = COALESCE(?, icon), assigned_to = ?, recurrence_rule = ?, + attachment_name = ?, + attachment_mime = ?, + attachment_size = ?, + attachment_data = ?, user_modified = ? WHERE id = ? `).run( @@ -679,6 +724,10 @@ router.put('/:id', (req, res) => { req.body.icon !== undefined ? vIcon : null, assigned_to !== undefined ? (assigned_to || null) : event.assigned_to, recurrence_rule !== undefined ? (recurrence_rule || null) : event.recurrence_rule, + attachment_name !== undefined ? (attachment_name || null) : event.attachment_name, + attachment.mime, + attachment.size, + attachment.data, userModified, id ); From 3fc95da48fc6ef8977f829f9de2f45b1d0bc8155 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Wed, 29 Apr 2026 16:53:45 +0200 Subject: [PATCH 4/4] fix: innerHTML violations, German translations, and PT diacritics - Replace innerHTML with replaceChildren/insertAdjacentHTML in birthdays.js and calendar.js (hook compliance) - Translate calendar attachment i18n keys to German in de.json - Fix missing diacritical marks in pt.json attachment strings Co-Authored-By: Claude Sonnet 4.6 --- public/locales/de.json | 10 +++++----- public/locales/pt.json | 4 ++-- public/pages/birthdays.js | 3 ++- public/pages/calendar.js | 6 +++--- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index 8644c62..bc269bf 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -376,11 +376,11 @@ }, "iconLabel": "Icon", "invalidDate": "Bitte ein gültiges Datum im ausgewählten Format verwenden.", - "attachmentLabel": "Attachment", - "attachmentHint": "Attach a local image, PDF, or document. Images will be shown in the event popup.", - "attachmentFallback": "Attachment", - "attachmentReadError": "The attachment could not be read.", - "attachmentTooLarge": "Attachment may be at most 5 MB." + "attachmentLabel": "Anhang", + "attachmentHint": "Lokales Bild, PDF oder Dokument anhängen. Bilder werden im Ereignis-Popup angezeigt.", + "attachmentFallback": "Anhang", + "attachmentReadError": "Der Anhang konnte nicht gelesen werden.", + "attachmentTooLarge": "Der Anhang darf höchstens 5 MB groß sein." }, "notes": { "title": "Notizen", diff --git a/public/locales/pt.json b/public/locales/pt.json index d74decc..e02332e 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -362,8 +362,8 @@ "attachmentLabel": "Anexo", "attachmentHint": "Anexe uma imagem, PDF ou documento local. Imagens aparecem no pop-up do evento.", "attachmentFallback": "Anexo", - "attachmentReadError": "Nao foi possivel ler o anexo.", - "attachmentTooLarge": "O anexo pode ter no maximo 5 MB." + "attachmentReadError": "Não foi possível ler o anexo.", + "attachmentTooLarge": "O anexo pode ter no máximo 5 MB." }, "notes": { "title": "Quadro de notas", diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index cbb2f23..468ce68 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -322,7 +322,8 @@ function openBirthdayModal({ mode, birthday = null }) { const fileInput = panel.querySelector('#bd-photo'); const photoEdit = panel.querySelector('#bd-photo-edit'); const renderPreview = () => { - preview.innerHTML = birthdayPreviewHtml(nameInput.value.trim(), photoData); + preview.replaceChildren(); + preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData)); }; nameInput.addEventListener('input', renderPreview); preview.addEventListener('click', () => fileInput?.click()); diff --git a/public/pages/calendar.js b/public/pages/calendar.js index f521330..c0ee129 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -1266,16 +1266,16 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { }; const syncAttachmentPreview = () => { if (!attachmentPreview) return; - attachmentPreview.innerHTML = ''; + attachmentPreview.replaceChildren(); if (!attachmentState.data) { attachmentPreview.hidden = true; return; } attachmentPreview.hidden = false; if (isImageAttachment(attachmentState.mime)) { - attachmentPreview.innerHTML = `${esc(attachmentState.name || '')}`; + attachmentPreview.insertAdjacentHTML('beforeend', `${esc(attachmentState.name || '')}`); } else { - attachmentPreview.innerHTML = `${esc(attachmentState.name || '')}`; + attachmentPreview.insertAdjacentHTML('beforeend', `${esc(attachmentState.name || '')}`); } }; attachmentInput?.addEventListener('change', async () => {