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