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:
@@ -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": "لوحة الملاحظات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Σημειώσεις",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "नोट बोर्ड",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メモボード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Заметки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Нотатки",
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user