fix(calendar): tighten modal and attachment rendering
This commit is contained in:
+5
-19
@@ -31,6 +31,11 @@
|
|||||||
<link rel="modulepreload" href="/router.js" />
|
<link rel="modulepreload" href="/router.js" />
|
||||||
<link rel="modulepreload" href="/rrule-ui.js" />
|
<link rel="modulepreload" href="/rrule-ui.js" />
|
||||||
|
|
||||||
|
<!-- Theme: explizite Nutzer-Overrides vor CSS-Rendering anwenden (Flash-Prevention).
|
||||||
|
System-Präferenz wird durch @media (prefers-color-scheme: dark) in tokens.css
|
||||||
|
direkt per CSS behandelt — kein JS-matchMedia erforderlich. -->
|
||||||
|
<script src="/theme-init.js"></script>
|
||||||
|
|
||||||
<!-- Styles (Basis - seitenspezifische CSS wird vom Router on-demand geladen) -->
|
<!-- Styles (Basis - seitenspezifische CSS wird vom Router on-demand geladen) -->
|
||||||
<link rel="stylesheet" href="/styles/tokens.css" />
|
<link rel="stylesheet" href="/styles/tokens.css" />
|
||||||
<link rel="stylesheet" href="/styles/reset.css" />
|
<link rel="stylesheet" href="/styles/reset.css" />
|
||||||
@@ -39,30 +44,11 @@
|
|||||||
<link rel="stylesheet" href="/styles/glass.css" />
|
<link rel="stylesheet" href="/styles/glass.css" />
|
||||||
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
||||||
<link rel="stylesheet" href="/styles/login.css" />
|
<link rel="stylesheet" href="/styles/login.css" />
|
||||||
<!-- Theme: explizite Nutzer-Overrides vor CSS-Rendering anwenden (Flash-Prevention).
|
|
||||||
System-Präferenz wird durch @media (prefers-color-scheme: dark) in tokens.css
|
|
||||||
direkt per CSS behandelt — kein JS-matchMedia erforderlich. -->
|
|
||||||
<script>
|
|
||||||
(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');
|
|
||||||
}
|
|
||||||
// System/null: tokens.css @media (prefers-color-scheme: dark) übernimmt
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Lucide Icons (lokal, v0.469.0) -->
|
<!-- Lucide Icons (lokal, v0.469.0) -->
|
||||||
<script src="/lucide.min.js"></script>
|
<script src="/lucide.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Skip-Link: sichtbar bei Keyboard-Fokus, verknüpft mit <main id="main-content"> -->
|
|
||||||
<a href="#main-content" class="sr-only">Zum Hauptinhalt springen</a>
|
|
||||||
|
|
||||||
<!-- Offline-Banner: sichtbar wenn navigator.onLine === false -->
|
<!-- Offline-Banner: sichtbar wenn navigator.onLine === false -->
|
||||||
<div id="offline-banner" class="offline-banner" hidden aria-live="polite" role="status">
|
<div id="offline-banner" class="offline-banner" hidden aria-live="polite" role="status">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
|||||||
+117
-46
@@ -327,22 +327,43 @@ function readFileAsDataUrl(file) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentDataUrl(data, mime) {
|
||||||
|
const raw = String(data || '');
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.startsWith('data:')) return raw;
|
||||||
|
return mime ? `data:${mime};base64,${raw}` : raw;
|
||||||
|
}
|
||||||
|
|
||||||
function attachmentHtml(event) {
|
function attachmentHtml(event) {
|
||||||
if (!event?.attachment_data) return '';
|
if (!event?.attachment_data) return '';
|
||||||
const name = esc(event.attachment_name || t('calendar.attachmentFallback'));
|
const name = esc(event.attachment_name || t('calendar.attachmentFallback'));
|
||||||
|
const src = esc(attachmentDataUrl(event.attachment_data, event.attachment_mime));
|
||||||
if (isImageAttachment(event.attachment_mime)) {
|
if (isImageAttachment(event.attachment_mime)) {
|
||||||
return `
|
return `
|
||||||
<div class="event-popup__attachment event-popup__attachment--image">
|
<div class="event-popup__attachment event-popup__attachment--image">
|
||||||
<img src="${event.attachment_data}" alt="${name}">
|
<img src="${src}" alt="${name}">
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
return `
|
return `
|
||||||
<a class="event-popup__attachment event-popup__attachment--file" href="${event.attachment_data}" download="${name}">
|
<a class="event-popup__attachment event-popup__attachment--file" href="${src}" download="${name}">
|
||||||
<i data-lucide="paperclip" aria-hidden="true"></i>
|
<i data-lucide="paperclip" aria-hidden="true"></i>
|
||||||
<span>${name}</span>
|
<span>${name}</span>
|
||||||
</a>`;
|
</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
? `<img src="${src}" alt="${name}">`
|
||||||
|
: `<a href="${src}" download="${name}">${name}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedAttachmentLabel(name) {
|
||||||
|
return t('documents.selectedFileLabel', { name: name || t('calendar.attachmentFallback') });
|
||||||
|
}
|
||||||
|
|
||||||
function bindDateInputs(root) {
|
function bindDateInputs(root) {
|
||||||
root.querySelectorAll('.js-date-input').forEach((input) => {
|
root.querySelectorAll('.js-date-input').forEach((input) => {
|
||||||
input.addEventListener('blur', () => {
|
input.addEventListener('blur', () => {
|
||||||
@@ -1028,12 +1049,24 @@ function showEventPopup(ev, anchor) {
|
|||||||
popup.querySelector('.event-popup__actions').before(resetLink);
|
popup.querySelector('.event-popup__actions').before(resetLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Positionierung
|
// Positionierung: erst messen, dann im Viewport halten.
|
||||||
const rect = anchor.getBoundingClientRect();
|
const rect = anchor.getBoundingClientRect();
|
||||||
const top = Math.min(rect.bottom + 8, window.innerHeight - 280);
|
const gap = 8;
|
||||||
const left = Math.min(rect.left, window.innerWidth - 340);
|
const margin = 8;
|
||||||
popup.style.top = `${Math.max(8, top)}px`;
|
const popupRect = popup.getBoundingClientRect();
|
||||||
popup.style.left = `${Math.max(8, left)}px`;
|
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.querySelector('#popup-edit').addEventListener('click', async () => {
|
||||||
popup.remove();
|
popup.remove();
|
||||||
@@ -1257,6 +1290,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
|||||||
const reminderOffset = panel.querySelector('#modal-reminder-offset');
|
const reminderOffset = panel.querySelector('#modal-reminder-offset');
|
||||||
const reminderCustom = panel.querySelector('#modal-reminder-custom');
|
const reminderCustom = panel.querySelector('#modal-reminder-custom');
|
||||||
const attachmentInput = panel.querySelector('#modal-attachment');
|
const attachmentInput = panel.querySelector('#modal-attachment');
|
||||||
|
const selectedAttachment = panel.querySelector('#modal-selected-attachment');
|
||||||
const attachmentPreview = panel.querySelector('#modal-attachment-preview');
|
const attachmentPreview = panel.querySelector('#modal-attachment-preview');
|
||||||
const attachmentState = {
|
const attachmentState = {
|
||||||
name: event?.attachment_name || null,
|
name: event?.attachment_name || null,
|
||||||
@@ -1264,39 +1298,55 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
|
|||||||
size: event?.attachment_size || null,
|
size: event?.attachment_size || null,
|
||||||
data: event?.attachment_data || null,
|
data: event?.attachment_data || null,
|
||||||
};
|
};
|
||||||
const syncAttachmentPreview = () => {
|
|
||||||
if (!attachmentPreview) return;
|
const syncSelectedAttachment = () => {
|
||||||
attachmentPreview.replaceChildren();
|
if (!selectedAttachment) return;
|
||||||
if (!attachmentState.data) {
|
selectedAttachment.hidden = !attachmentState.name;
|
||||||
attachmentPreview.hidden = true;
|
selectedAttachment.textContent = attachmentState.name ? selectedAttachmentLabel(attachmentState.name) : '';
|
||||||
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 syncAttachmentSelection = () => {
|
||||||
|
if (!selectedAttachment) return;
|
||||||
const file = attachmentInput.files?.[0];
|
const file = attachmentInput.files?.[0];
|
||||||
if (!file) return;
|
if (file) {
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
selectedAttachment.hidden = false;
|
||||||
window.oikos?.showToast(t('calendar.attachmentTooLarge'), 'error');
|
selectedAttachment.textContent = selectedAttachmentLabel(file.name);
|
||||||
attachmentInput.value = '';
|
if (attachmentPreview) {
|
||||||
|
attachmentPreview.replaceChildren();
|
||||||
|
attachmentPreview.hidden = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
syncSelectedAttachment();
|
||||||
attachmentState.data = await readFileAsDataUrl(file);
|
};
|
||||||
attachmentState.name = file.name;
|
|
||||||
attachmentState.mime = file.type || 'application/octet-stream';
|
attachmentInput?.addEventListener('change', syncAttachmentSelection);
|
||||||
attachmentState.size = file.size;
|
|
||||||
syncAttachmentPreview();
|
const attachmentDropzone = panel.querySelector('#modal-attachment-dropzone');
|
||||||
} catch (err) {
|
if (attachmentDropzone && attachmentInput) {
|
||||||
window.oikos?.showToast(err.message, 'danger');
|
['dragenter', 'dragover'].forEach((eventName) => {
|
||||||
}
|
attachmentDropzone.addEventListener(eventName, (dropEvent) => {
|
||||||
});
|
dropEvent.preventDefault();
|
||||||
syncAttachmentPreview();
|
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', () => {
|
reminderOffset?.addEventListener('change', () => {
|
||||||
if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom';
|
if (reminderCustom) reminderCustom.hidden = reminderOffset.value !== 'custom';
|
||||||
});
|
});
|
||||||
@@ -1445,14 +1495,20 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="modal-attachment">${t('calendar.attachmentLabel')}</label>
|
<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">
|
<label class="document-dropzone" id="modal-attachment-dropzone" for="modal-attachment">
|
||||||
|
<input class="sr-only" 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">
|
||||||
|
<span class="document-dropzone__icon">
|
||||||
|
<i data-lucide="file-up" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<span class="document-dropzone__title">${t('documents.dropzoneTitle')}</span>
|
||||||
|
<span class="document-dropzone__hint">${t('documents.dropzoneHint')}</span>
|
||||||
|
<span class="document-dropzone__file" id="modal-selected-attachment" ${isEdit && event.attachment_name ? '' : 'hidden'}>
|
||||||
|
${isEdit && event.attachment_name ? esc(selectedAttachmentLabel(event.attachment_name)) : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<div class="form-help">${t('calendar.attachmentHint')}</div>
|
<div class="form-help">${t('calendar.attachmentHint')}</div>
|
||||||
<div class="event-attachment-preview" id="modal-attachment-preview" ${isEdit && event.attachment_data ? '' : 'hidden'}>
|
<div class="event-attachment-preview" id="modal-attachment-preview" ${isEdit && event.attachment_data ? '' : 'hidden'}>
|
||||||
${isEdit && event.attachment_data
|
${isEdit && event.attachment_data ? attachmentPreviewHtml(event) : ''}
|
||||||
? (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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1524,15 +1580,30 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
|
|||||||
saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create');
|
saveBtn.textContent = mode === 'edit' ? t('common.save') : t('common.create');
|
||||||
return;
|
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 = {
|
const body = {
|
||||||
title, description, start_datetime, end_datetime,
|
title, description, start_datetime, end_datetime,
|
||||||
all_day: allday ? 1 : 0,
|
all_day: allday ? 1 : 0,
|
||||||
location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
|
location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
|
||||||
recurrence_rule: rrule.recurrence_rule,
|
recurrence_rule: rrule.recurrence_rule,
|
||||||
attachment_name: attachmentState?.name || null,
|
attachment_name: attachmentPayload.name,
|
||||||
attachment_mime: attachmentState?.mime || null,
|
attachment_mime: attachmentPayload.mime,
|
||||||
attachment_size: attachmentState?.size || null,
|
attachment_size: attachmentPayload.size,
|
||||||
attachment_data: attachmentState?.data || null,
|
attachment_data: attachmentPayload.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
let savedEventId = eventId;
|
let savedEventId = eventId;
|
||||||
|
|||||||
@@ -445,6 +445,67 @@
|
|||||||
word-break: break-word;
|
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
|
* Tagesansicht
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -922,9 +922,13 @@
|
|||||||
background-color: var(--color-overlay);
|
background-color: var(--color-overlay);
|
||||||
z-index: var(--z-modal);
|
z-index: var(--z-modal);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
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;
|
animation: modal-overlay-in 0.2s ease forwards;
|
||||||
/* iOS Safari: click-Events auf non-interactive divs erfordern cursor:pointer */
|
/* iOS Safari: click-Events auf non-interactive divs erfordern cursor:pointer */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -932,7 +936,6 @@
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,11 +943,11 @@
|
|||||||
.modal-panel {
|
.modal-panel {
|
||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
width: 100%;
|
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;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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);
|
border: 1px solid var(--color-border-subtle);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
animation: modal-slide-up 0.25s var(--ease-out) forwards;
|
animation: modal-slide-up 0.25s var(--ease-out) forwards;
|
||||||
@@ -955,6 +958,7 @@
|
|||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.modal-panel {
|
.modal-panel {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
max-height: calc(100dvh - (2 * var(--space-6)));
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
animation: modal-scale-in 0.2s var(--ease-out) forwards;
|
animation: modal-scale-in 0.2s var(--ease-out) forwards;
|
||||||
}
|
}
|
||||||
@@ -1106,7 +1110,7 @@
|
|||||||
|
|
||||||
@keyframes sheet-out {
|
@keyframes sheet-out {
|
||||||
from { transform: translateY(0); }
|
from { transform: translateY(0); }
|
||||||
to { transform: translateY(100%); }
|
to { transform: translateY(16px); opacity: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
})();
|
||||||
+1
-7
@@ -55,13 +55,7 @@ app.use(helmet({
|
|||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: [
|
scriptSrc: ["'self'"],
|
||||||
"'self'",
|
|
||||||
// Inline-Script: Theme-Detection (Flash-Prevention)
|
|
||||||
"'sha256-vqqBNo1oitnzIntwkG83UaYqkUAnV/oZ/RkvcA41Y6A='",
|
|
||||||
// Alpine.js CDN (optional, falls verwendet)
|
|
||||||
'https://cdn.jsdelivr.net',
|
|
||||||
],
|
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
imgSrc: ["'self'", 'data:'],
|
imgSrc: ["'self'", 'data:'],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'"],
|
||||||
|
|||||||
@@ -86,6 +86,21 @@ function parseAttachment(dataUrl) {
|
|||||||
return { name: null, mime, size: buffer.length, data: base64 };
|
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
|
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
|
||||||
// innerhalb [from, to] generieren (inklusive beider Grenzen).
|
// 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';
|
sql += ' ORDER BY e.start_datetime ASC, e.all_day DESC';
|
||||||
|
|
||||||
const rawEvents = db.get().prepare(sql).all(...params);
|
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 });
|
res.json({ data: events, from, to });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
@@ -271,7 +286,8 @@ router.get('/upcoming', (req, res) => {
|
|||||||
|
|
||||||
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
|
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
|
||||||
.filter((e) => e.start_datetime >= new Date().toISOString())
|
.filter((e) => e.start_datetime >= new Date().toISOString())
|
||||||
.slice(0, limit);
|
.slice(0, limit)
|
||||||
|
.map(serializeEvent);
|
||||||
|
|
||||||
res.json({ data: expanded });
|
res.json({ data: expanded });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -571,7 +587,7 @@ router.get('/:id', (req, res) => {
|
|||||||
`).get(id);
|
`).get(id);
|
||||||
|
|
||||||
if (!event) return res.status(404).json({ error: 'Termin nicht gefunden', code: 404 });
|
if (!event) return res.status(404).json({ error: 'Termin nicht gefunden', code: 404 });
|
||||||
res.json({ data: event });
|
res.json({ data: serializeEvent(event) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
@@ -649,7 +665,7 @@ router.post('/', (req, res) => {
|
|||||||
WHERE e.id = ?
|
WHERE e.id = ?
|
||||||
`).get(result.lastInsertRowid);
|
`).get(result.lastInsertRowid);
|
||||||
|
|
||||||
res.status(201).json({ data: event });
|
res.status(201).json({ data: serializeEvent(event) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
@@ -743,7 +759,7 @@ router.put('/:id', (req, res) => {
|
|||||||
WHERE e.id = ?
|
WHERE e.id = ?
|
||||||
`).get(id);
|
`).get(id);
|
||||||
|
|
||||||
res.json({ data: updated });
|
res.json({ data: serializeEvent(updated) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
|||||||
Reference in New Issue
Block a user