feat: overlapping event layout and calendar event attachments (#107)

- Overlapping timed events in week/day views now render side-by-side using a column-layout algorithm
- Calendar events support optional file attachments (images, PDFs, documents up to 5 MB)
- Attachment images shown in event popup; other files as download links
- Birthday modal redesigned with photo/avatar side-by-side with name/date fields
- DB migration 27: adds attachment_name, attachment_mime, attachment_size, attachment_data columns to calendar_events
- Server-side MIME allowlist and size validation for attachments
- i18n: all 15 locales include new attachment keys (de properly translated)

Co-Authored-By: Rafael Foster <rafaelfoster@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-29 16:54:02 +02:00
23 changed files with 477 additions and 57 deletions
+6 -1
View File
@@ -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": "لوحة الملاحظات",
+6 -1
View File
@@ -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": "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",
+6 -1
View File
@@ -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": "Σημειώσεις",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "नोट बोर्ड",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "メモボード",
+6 -1
View File
@@ -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": "Não foi possível ler o anexo.",
"attachmentTooLarge": "O anexo pode ter no máximo 5 MB."
},
"notes": {
"title": "Quadro de notas",
+6 -1
View File
@@ -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": "Заметки",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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": "Нотатки",
+6 -1
View File
@@ -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": "便签板",
+22 -15
View File
@@ -275,7 +275,22 @@ function openBirthdayModal({ mode, birthday = null }) {
title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'),
content: `
<div class="birthday-modal">
<div class="birthday-preview" id="birthday-preview">${birthdayPreviewHtml(birthday?.name || '', photoData)}</div>
<div class="birthday-modal__identity">
<div class="birthday-modal__photo-wrap">
<button type="button" class="birthday-avatar-editor" id="birthday-preview" aria-label="${t('birthdays.photoLabel')}">
${birthdayPreviewHtml(birthday?.name || '', photoData)}
</button>
<input class="sr-only" id="bd-photo" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
<div class="birthday-modal__photo-actions">
<button type="button" class="birthday-modal__photo-action" id="bd-photo-edit" aria-label="${t('birthdays.photoLabel')}" title="${t('birthdays.photoLabel')}">
<i data-lucide="pencil" aria-hidden="true"></i>
</button>
<button type="button" class="birthday-modal__photo-action birthday-modal__photo-action--danger" id="bd-remove-photo" aria-label="${t('birthdays.removePhoto')}" title="${t('birthdays.removePhoto')}">
<i data-lucide="trash-2" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="birthday-modal__fields">
<div class="form-group">
<label class="form-label" for="bd-name">${t('birthdays.nameLabel')}</label>
<input class="form-input" id="bd-name" type="text" value="${esc(birthday?.name || '')}" autocomplete="name">
@@ -284,12 +299,6 @@ function openBirthdayModal({ mode, birthday = null }) {
<label class="form-label" for="bd-birth-date">${t('birthdays.birthDateLabel')}</label>
<input class="form-input" id="bd-birth-date" type="date" value="${esc(birthday?.birth_date || '')}">
</div>
<div class="form-group">
<label class="form-label" for="bd-photo">${t('birthdays.photoLabel')}</label>
<input class="form-input" id="bd-photo" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
<div class="form-help">${t('birthdays.photoOptional')}</div>
<div class="birthday-modal__photo-actions">
<button type="button" class="btn btn--secondary" id="bd-remove-photo">${t('birthdays.removePhoto')}</button>
</div>
</div>
<div class="form-group">
@@ -310,18 +319,16 @@ 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));
};
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 +340,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);
+161 -10
View File
@@ -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 `
<div class="event-popup__attachment event-popup__attachment--image">
<img src="${event.attachment_data}" alt="${name}">
</div>`;
}
return `
<a class="event-popup__attachment event-popup__attachment--file" href="${event.attachment_data}" download="${name}">
<i data-lucide="paperclip" aria-hidden="true"></i>
<span>${name}</span>
</a>`;
}
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 = `
<div class="week-view">
@@ -674,7 +706,7 @@ function renderWeekView(container) {
${Array.from({ length: 24 }, (_, h) => `
<div class="week-view__hour-line" style="top:${h * HOUR_HEIGHT}px;"></div>
`).join('')}
${timedEvs[i].map((ev) => renderWeekEvent(ev)).join('')}
${timedEvs[i].map((ev) => renderWeekEvent(ev, layouts[i].get(ev.id))).join('')}
${d === state.today ? `<div class="week-view__now-line" id="now-line" style="top:${nowTop()}px;"></div>` : ''}
</div>
`).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 `
<div class="week-event" data-id="${ev.id}"
style="top:${top}px;height:${height}px;${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)};` : ''}">
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)};` : ''}">
<div class="week-event__title">${eventIconHtml(ev.icon, 'event-icon event-icon--compact')}<span>${esc(ev.title)}</span></div>
<div class="week-event__time">${formatTime(ev.start_datetime)}${ev.end_datetime ? '' + formatTime(ev.end_datetime) : ''}</div>
</div>
@@ -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 = `
<div class="day-view">
@@ -781,7 +873,7 @@ function renderDayView(container) {
${Array.from({ length: 24 }, (_, h) => `
<div class="week-view__hour-line" style="top:${h * HOUR_HEIGHT}px;"></div>
`).join('')}
${timed.map((ev) => renderWeekEvent(ev)).join('')}
${timed.map((ev) => renderWeekEvent(ev, layout.get(ev.id))).join('')}
${state.cursor === state.today ? `<div class="week-view__now-line" style="top:${nowTop()}px;"></div>` : ''}
</div>
</div>
@@ -902,6 +994,7 @@ function showEventPopup(ev, anchor) {
<div>${timeStr}</div>
${ev.location ? `<div>📍 ${esc(fmtLocation(ev.location))}</div>` : ''}
${ev.description ? `<div>${esc(ev.description)}</div>` : ''}
${ev.attachment_data ? attachmentHtml(ev) : ''}
${ev.assigned_name ? `<div>👤 ${esc(ev.assigned_name)}</div>` : ''}
</div>
<div class="event-popup__actions">
@@ -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.replaceChildren();
if (!attachmentState.data) {
attachmentPreview.hidden = true;
return;
}
attachmentPreview.hidden = false;
if (isImageAttachment(attachmentState.mime)) {
attachmentPreview.insertAdjacentHTML('beforeend', `<img src="${attachmentState.data}" alt="${esc(attachmentState.name || '')}">`);
} else {
attachmentPreview.insertAdjacentHTML('beforeend', `<a href="${attachmentState.data}" download="${esc(attachmentState.name || '')}">${esc(attachmentState.name || '')}</a>`);
}
};
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 : '')}</textarea>
</div>
<div class="form-group">
<label class="form-label" for="modal-attachment">${t('calendar.attachmentLabel')}</label>
<input class="form-input" id="modal-attachment" type="file" accept="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">
<div class="form-help">${t('calendar.attachmentHint')}</div>
<div class="event-attachment-preview" id="modal-attachment-preview" ${isEdit && event.attachment_data ? '' : 'hidden'}>
${isEdit && event.attachment_data
? (isImageAttachment(event.attachment_mime)
? `<img src="${event.attachment_data}" alt="${esc(event.attachment_name || '')}">`
: `<a href="${event.attachment_data}" download="${esc(event.attachment_name || '')}">${esc(event.attachment_name || '')}</a>`)
: ''}
</div>
</div>
${renderRRuleFields('event', isEdit ? event.recurrence_rule : null)}
${renderCalendarReminderSection(reminder, event)}
@@ -1324,7 +1471,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
</div>`;
}
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;
+70 -1
View File
@@ -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,51 @@
}
.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__photo-wrap {
width: 84px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.birthday-modal__fields {
flex: 1;
min-width: 0;
}
.birthday-modal__hint {
@@ -308,6 +367,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;
+42 -2
View File
@@ -391,8 +391,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);
@@ -424,6 +422,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
* -------------------------------------------------------- */
@@ -768,6 +789,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)
* -------------------------------------------------------- */
+6
View File
@@ -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 };
+10
View File
@@ -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;
`,
},
];
/**
+15 -2
View File
@@ -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': {
+53 -4
View File
@@ -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
);