collab attachments, multi-day events, calendar improvements
- Collab buckets sync attachments alongside cards (images/PDFs/audio). New /api/collab-attachments endpoint on relay, encrypted at rest, bucket-scoped for members. - CalDAV multi-day events via **Bis:** body field. Calendar renders event on every day in range with day counter (1/6, 2/6, ...). - Calendar preserves month across close/reopen (only jumps to today on first open). - Calendar title: centered nav group with [MM-DD] Mon Y [KW-KW] — dimmed brackets, fixed width so nothing wiggles. - Editor: scratchpad uses custom confirm, autobackup-draft dialog replaced with custom styled dialog. - Scanlines theme feature removed (performance issue). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
002562b1d3
commit
ca47052768
11 files changed
+254
-64
No files matched your search
@@ -88,20 +88,35 @@ function matchesRRule(card, targetDateStr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getMultiDayEnd(card) {
|
||||
const m = (card.body || '').match(/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function getCardsForDate(ds) {
|
||||
const results = [];
|
||||
const targetYear = parseInt(ds.substring(0, 4));
|
||||
for (const c of state.cards) {
|
||||
if (cardBaseDate(c) === ds) {
|
||||
const base = cardBaseDate(c);
|
||||
if (base === ds) {
|
||||
results.push({ ...c, _recurring: false });
|
||||
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
|
||||
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
|
||||
} else if (base && base < ds) {
|
||||
// Multi-day event: check if ds is within [base..end]
|
||||
const end = getMultiDayEnd(c);
|
||||
if (end && ds <= end) {
|
||||
const startMs = Date.parse(base);
|
||||
const curMs = Date.parse(ds);
|
||||
const dayNum = Math.round((curMs - startMs) / 86400000) + 1;
|
||||
const totalDays = Math.round((Date.parse(end) - startMs) / 86400000) + 1;
|
||||
results.push({ ...c, _recurring: false, _multiDay: { day: dayNum, total: totalDays, isEnd: ds === end } });
|
||||
}
|
||||
}
|
||||
if (c.caldav_rrule && matchesRRule(c, ds)) {
|
||||
const baseYear = parseInt(base.substring(0, 4));
|
||||
const rrule = parseRRule(c.caldav_rrule);
|
||||
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
||||
results.push({ ...c, _recurring: true, _age: age });
|
||||
if (base !== ds) results.push({ ...c, _recurring: true, _age: age });
|
||||
} else if ((c.tags || '').split(',').includes('geburtstag')) {
|
||||
// Local birthday cards repeat yearly without RRULE
|
||||
const base = cardBaseDate(c);
|
||||
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
||||
const age = targetYear - parseInt(base.substring(0, 4));
|
||||
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
||||
@@ -146,7 +161,25 @@ function clearCalHover() {
|
||||
}
|
||||
|
||||
function render() {
|
||||
title.textContent = `${MONTHS[state.calMonth]} ${state.calYear}`;
|
||||
// Format: [MM-DD] Mon Y [KW-KW]
|
||||
// MM-DD = today's month-day
|
||||
// Mon Y = displayed month + year (abbreviated)
|
||||
// KW-KW = ISO week range spanning the displayed month
|
||||
const isoWeek = (y, m, d) => {
|
||||
const dt = new Date(Date.UTC(y, m, d));
|
||||
const dayNum = dt.getUTCDay() || 7;
|
||||
dt.setUTCDate(dt.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((dt - yearStart) / 86400000) + 1) / 7);
|
||||
};
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const nowDate = new Date();
|
||||
const todayMd = `${pad(nowDate.getMonth() + 1)}-${pad(nowDate.getDate())}`;
|
||||
const monAbbr = MONTHS[state.calMonth].substring(0, 3);
|
||||
const lastDay = new Date(state.calYear, state.calMonth + 1, 0).getDate();
|
||||
const wStart = isoWeek(state.calYear, state.calMonth, 1);
|
||||
const wEnd = isoWeek(state.calYear, state.calMonth, lastDay);
|
||||
title.innerHTML = `<span style="color:var(--t-dim)">[${todayMd}]</span> ${monAbbr} ${state.calYear} <span style="color:var(--t-dim)">[${pad(wStart)}-${pad(wEnd)}]</span>`;
|
||||
|
||||
const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
|
||||
const startOffset = (firstDay + 6) % 7; // Mon=0
|
||||
@@ -210,9 +243,11 @@ function render() {
|
||||
const calCfg = getCardCalConfig(c);
|
||||
const itemColor = calCfg?.color || c.color;
|
||||
const itemIcon = calCfg?.icon || null;
|
||||
const md = c._multiDay;
|
||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
||||
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
|
||||
if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
||||
if (md) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:${itemColor};flex-shrink:0;">${md.day}/${md.total}</span>`;
|
||||
else if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
|
||||
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
|
||||
html += `<span class="truncate">${esc(name)}</span>`;
|
||||
html += `</div>`;
|
||||
@@ -494,9 +529,14 @@ export function refreshCalendar() {
|
||||
}
|
||||
|
||||
export function openCalendar() {
|
||||
const now = new Date();
|
||||
state.calYear = now.getFullYear();
|
||||
state.calMonth = now.getMonth();
|
||||
// Only jump to today on first open — preserve the user's navigation
|
||||
// when the calendar is re-opened (e.g. after saving a card in a
|
||||
// different month).
|
||||
if (state.calYear == null || state.calMonth == null) {
|
||||
const now = new Date();
|
||||
state.calYear = now.getFullYear();
|
||||
state.calMonth = now.getMonth();
|
||||
}
|
||||
render();
|
||||
overlay.classList.add('open');
|
||||
overlay.style.visibility = '';
|
||||
|
||||
+21
-11
@@ -4,6 +4,7 @@ import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js';
|
||||
import { state, getChildren, CARD_COLORS } from './state.js';
|
||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||
import { showDialog } from './dialog.js';
|
||||
|
||||
const modal = $('modal');
|
||||
const body = $('editor-body');
|
||||
@@ -238,7 +239,7 @@ export function openModal(card = null) {
|
||||
updateActiveLine();
|
||||
|
||||
// Check for unsaved draft (newer than what we just loaded).
|
||||
// Skipped during test runs — tests would block on the confirm dialog.
|
||||
// Skipped during test runs — tests would block on the dialog.
|
||||
const draft = loadDraft(card?.id);
|
||||
if (draft && !window._testRunning) {
|
||||
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
||||
@@ -248,18 +249,27 @@ export function openModal(card = null) {
|
||||
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
||||
const age = Math.round((Date.now() - draft.savedAt) / 60000);
|
||||
const ageText = age < 1 ? 'just now' : age < 60 ? `${age} min ago` : `${Math.round(age / 60)} h ago`;
|
||||
const restore = window.confirm(`Unsaved draft from ${ageText} found.\n\nRestore it?`);
|
||||
if (restore) {
|
||||
inpTitle.value = draft.title || '';
|
||||
body.value = draft.body || '';
|
||||
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
||||
updatePreview();
|
||||
} else {
|
||||
clearDraft(card?.id);
|
||||
}
|
||||
const cardId = card?.id;
|
||||
// Async confirm — fires after openModal returns
|
||||
(async () => {
|
||||
const restore = await showDialog({
|
||||
title: 'unsaved draft',
|
||||
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
|
||||
okLabel: 'Restore',
|
||||
cancelLabel: 'Discard',
|
||||
});
|
||||
if (!modal.classList.contains('open') || state.editingId != cardId) return;
|
||||
if (restore) {
|
||||
inpTitle.value = draft.title || '';
|
||||
body.value = draft.body || '';
|
||||
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
||||
updatePreview();
|
||||
} else {
|
||||
clearDraft(cardId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else if (draft && window._testRunning) {
|
||||
// During tests, silently discard any draft so it doesn't accumulate
|
||||
clearDraft(card?.id);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,6 @@ function updatePreview() {
|
||||
set('--tp-bar-height', d.barHeight + 'px');
|
||||
set('--tp-pad-card', d.padCard + 'px');
|
||||
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
||||
const scan = $('theme-preview-scan');
|
||||
if (scan) scan.style.display = String(d.scanlines) === '1' ? '' : 'none';
|
||||
}
|
||||
|
||||
function fillFontSelect(id, value) {
|
||||
@@ -129,8 +127,6 @@ function syncControlsFromPending() {
|
||||
const valEl = $(valId);
|
||||
if (valEl) valEl.textContent = v;
|
||||
}
|
||||
const scanEl = $('setting-scanlines');
|
||||
if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1');
|
||||
}
|
||||
|
||||
function commitDesign() {
|
||||
@@ -167,16 +163,6 @@ function initDesignSettings() {
|
||||
if (designWired) return;
|
||||
designWired = true;
|
||||
|
||||
// Scanlines toggle
|
||||
const scanEl = $('setting-scanlines');
|
||||
const scanRow = scanEl?.closest('.toggle-row');
|
||||
if (scanRow) scanRow.onclick = () => {
|
||||
const nowOn = !scanEl.classList.contains('on');
|
||||
scanEl.classList.toggle('on', nowOn);
|
||||
designPending.scanlines = nowOn ? '1' : '0';
|
||||
commitDesign();
|
||||
};
|
||||
|
||||
// Selects
|
||||
for (const [id, key] of SELECT_FIELDS) {
|
||||
const el = $(id);
|
||||
|
||||
Reference in new issue
Block a user