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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.4.3');
|
define('APP_VERSION', '1.5.0');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ body { font-family: var(--t-font-content, 'JetBrains Mono', monospace); }
|
|||||||
}
|
}
|
||||||
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
|
.font-pixel { font-family: var(--t-font-ui, var(--t-font-pixel, 'Silkscreen', cursive)) !important; }
|
||||||
.brand { font-family: 'Silkscreen', cursive !important; }
|
.brand { font-family: 'Silkscreen', cursive !important; }
|
||||||
body.no-scanlines::after { display: none !important; }
|
|
||||||
#dialog-msg { white-space: pre-line; word-break: break-word; }
|
#dialog-msg { white-space: pre-line; word-break: break-word; }
|
||||||
|
|
||||||
/* ── User-customizable design tokens ── */
|
/* ── User-customizable design tokens ── */
|
||||||
@@ -172,7 +171,6 @@ body.anim-slow .card-spinner {
|
|||||||
position: relative; padding: 14px; overflow: hidden;
|
position: relative; padding: 14px; overflow: hidden;
|
||||||
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
|
background: var(--t-bg); border: 1px solid var(--t-border); border-radius: 3px;
|
||||||
}
|
}
|
||||||
.theme-preview .tp-scan { position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px); }
|
|
||||||
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
|
.theme-preview .tp-row { display: flex; gap: 12px; align-items: stretch; position: relative; z-index: 1; }
|
||||||
.theme-preview .tp-card {
|
.theme-preview .tp-card {
|
||||||
flex: 1; min-width: 0;
|
flex: 1; min-width: 0;
|
||||||
@@ -299,16 +297,6 @@ body.settings-peeking #settings-overlay > div { pointer-events: auto; }
|
|||||||
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
*::-webkit-scrollbar-thumb:hover { background: var(--t-dim); }
|
||||||
*::-webkit-scrollbar-corner { background: transparent; }
|
*::-webkit-scrollbar-corner { background: transparent; }
|
||||||
|
|
||||||
/* Scanlines */
|
|
||||||
body::after {
|
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
background: repeating-linear-gradient(0deg, transparent, transparent 2px, var(--t-scanline, rgba(0,0,0,0.04)) 2px, var(--t-scanline, rgba(0,0,0,0.04)) 4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blinking cursor */
|
/* Blinking cursor */
|
||||||
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
.cursor-blink::after { content: '_'; animation: blink 1s step-end infinite; }
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|||||||
+6
-11
@@ -1158,7 +1158,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
// color theme
|
// color theme
|
||||||
theme: 'slate',
|
theme: 'slate',
|
||||||
// typography
|
// typography
|
||||||
scanlines: '1',
|
|
||||||
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
|
fontHeadline: 'pixel', fontContent: 'mono', fontUi: 'pixel',
|
||||||
// geometry
|
// geometry
|
||||||
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
|
radiusCard: 0, radiusButton: 0, radiusInput: 0, radiusModal: 0, radiusTag: 0, radiusImage: 0,
|
||||||
@@ -1172,7 +1171,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
window.builtinPresets = {
|
window.builtinPresets = {
|
||||||
Terminal: { ...window.designDefaults },
|
Terminal: { ...window.designDefaults },
|
||||||
Modern: { theme: 'paper',
|
Modern: { theme: 'paper',
|
||||||
scanlines: '0',
|
|
||||||
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
|
fontHeadline: 'mono', fontContent: 'mono', fontUi: 'mono',
|
||||||
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
|
radiusCard: 10, radiusButton: 24, radiusInput: 5, radiusModal: 15, radiusTag: 3, radiusImage: 8,
|
||||||
borderCard: 1, borderStyle: 'solid', barHeight: 3,
|
borderCard: 1, borderStyle: 'solid', barHeight: 3,
|
||||||
@@ -1210,7 +1208,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
root.setProperty('--ui-lift-card', d.liftCard + 'px');
|
root.setProperty('--ui-lift-card', d.liftCard + 'px');
|
||||||
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
|
root.setProperty('--ui-blur-overlay', d.blurOverlay + 'px');
|
||||||
// body classes
|
// body classes
|
||||||
document.body.classList.toggle('no-scanlines', String(d.scanlines) !== '1');
|
|
||||||
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
|
document.body.classList.remove('anim-off', 'anim-fast', 'anim-slow');
|
||||||
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
|
if (d.animSpeed === 'off') document.body.classList.add('anim-off');
|
||||||
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
|
if (d.animSpeed === 'fast') document.body.classList.add('anim-fast');
|
||||||
@@ -1222,8 +1219,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
// Migrate legacy individual keys (only if no JSON blob exists yet)
|
// Migrate legacy individual keys (only if no JSON blob exists yet)
|
||||||
if (!Object.keys(saved).length) {
|
if (!Object.keys(saved).length) {
|
||||||
const legacy = {};
|
const legacy = {};
|
||||||
const sl = localStorage.getItem('hyprcrd-scanlines');
|
|
||||||
if (sl !== null) legacy.scanlines = sl;
|
|
||||||
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
|
const fh = localStorage.getItem('hyprcrd-font-headline'); if (fh) legacy.fontHeadline = fh;
|
||||||
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
|
const fc = localStorage.getItem('hyprcrd-font-content'); if (fc) legacy.fontContent = fc;
|
||||||
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
|
const fu = localStorage.getItem('hyprcrd-font-ui'); if (fu) legacy.fontUi = fu;
|
||||||
@@ -1649,7 +1644,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="theme-preview" class="theme-preview">
|
<div id="theme-preview" class="theme-preview">
|
||||||
<div class="tp-scan" id="theme-preview-scan"></div>
|
|
||||||
<div class="tp-row">
|
<div class="tp-row">
|
||||||
<div class="tp-card">
|
<div class="tp-card">
|
||||||
<div class="tp-card-bar"></div>
|
<div class="tp-card-bar"></div>
|
||||||
@@ -1696,7 +1690,6 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</summary>
|
</summary>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="design-section-label">typography</div>
|
<div class="design-section-label">typography</div>
|
||||||
<div class="toggle-row"><span class="toggle-label">Scan lines</span><div class="toggle-switch" id="setting-scanlines"></div></div>
|
|
||||||
<div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
<div class="design-row"><label>Headline</label><select id="setting-font-headline" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
||||||
<div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
<div class="design-row"><label>Content</label><select id="setting-font-content" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
||||||
<div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
<div class="design-row"><label>UI</label><select id="setting-font-ui" class="bg-t-bg border border-t-border text-t-bright text-xs font-pixel px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none cursor-pointer"></select></div>
|
||||||
@@ -2093,14 +2086,16 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<!-- Calendar -->
|
<!-- Calendar -->
|
||||||
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
|
<div class="overlay fixed inset-0 z-[73]" style="background:var(--t-overlay)" id="calendar">
|
||||||
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
<div class="bg-t-surface border border-t-border flex flex-col absolute" style="inset:40px;">
|
||||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border shrink-0">
|
<div class="flex items-center gap-2 px-5 py-3 border-b border-t-border shrink-0 relative">
|
||||||
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
|
<i class="ph ph-calendar-dots text-t-accent text-base"></i>
|
||||||
<button id="cal-prev" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
|
||||||
<h2 id="cal-title" class="font-pixel text-xs text-t-accent min-w-[120px] text-center"></h2>
|
|
||||||
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
|
|
||||||
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
<button id="cal-today" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">Today</button>
|
||||||
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
|
<button id="cal-sync" class="font-pixel text-[10px] px-2 py-0.5 border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors flex items-center gap-1"><i class="ph ph-cloud-arrow-up text-xs"></i>Sync</button>
|
||||||
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
<button id="cal-caldav-settings" class="text-t-dim hover:text-t-accent transition-colors"><i class="ph ph-gear text-sm"></i></button>
|
||||||
|
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-3">
|
||||||
|
<button id="cal-prev" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-left text-sm"></i></button>
|
||||||
|
<h2 id="cal-title" class="font-pixel text-sm text-t-accent text-center" style="width:220px;flex-shrink:0;"></h2>
|
||||||
|
<button id="cal-next" class="text-t-muted hover:text-t-accent transition-colors"><i class="ph ph-caret-right text-sm"></i></button>
|
||||||
|
</div>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
<button id="cal-close" class="flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger transition-colors">
|
||||||
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
<i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">Close</span>
|
||||||
|
|||||||
@@ -88,20 +88,35 @@ function matchesRRule(card, targetDateStr) {
|
|||||||
return false;
|
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) {
|
function getCardsForDate(ds) {
|
||||||
const results = [];
|
const results = [];
|
||||||
const targetYear = parseInt(ds.substring(0, 4));
|
const targetYear = parseInt(ds.substring(0, 4));
|
||||||
for (const c of state.cards) {
|
for (const c of state.cards) {
|
||||||
if (cardBaseDate(c) === ds) {
|
const base = cardBaseDate(c);
|
||||||
|
if (base === ds) {
|
||||||
results.push({ ...c, _recurring: false });
|
results.push({ ...c, _recurring: false });
|
||||||
} else if (c.caldav_rrule && matchesRRule(c, ds)) {
|
} else if (base && base < ds) {
|
||||||
const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
|
// 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 rrule = parseRRule(c.caldav_rrule);
|
||||||
const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
|
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')) {
|
} 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) {
|
if (base.substring(5) === ds.substring(5) && ds !== base) {
|
||||||
const age = targetYear - parseInt(base.substring(0, 4));
|
const age = targetYear - parseInt(base.substring(0, 4));
|
||||||
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
results.push({ ...c, _recurring: true, _age: age > 0 ? age : null });
|
||||||
@@ -146,7 +161,25 @@ function clearCalHover() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
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 firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun
|
||||||
const startOffset = (firstDay + 6) % 7; // Mon=0
|
const startOffset = (firstDay + 6) % 7; // Mon=0
|
||||||
@@ -210,9 +243,11 @@ function render() {
|
|||||||
const calCfg = getCardCalConfig(c);
|
const calCfg = getCardCalConfig(c);
|
||||||
const itemColor = calCfg?.color || c.color;
|
const itemColor = calCfg?.color || c.color;
|
||||||
const itemIcon = calCfg?.icon || null;
|
const itemIcon = calCfg?.icon || null;
|
||||||
|
const md = c._multiDay;
|
||||||
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
|
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 (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>`;
|
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 += `<span class="truncate">${esc(name)}</span>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
@@ -494,9 +529,14 @@ export function refreshCalendar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function openCalendar() {
|
export function openCalendar() {
|
||||||
const now = new Date();
|
// Only jump to today on first open — preserve the user's navigation
|
||||||
state.calYear = now.getFullYear();
|
// when the calendar is re-opened (e.g. after saving a card in a
|
||||||
state.calMonth = now.getMonth();
|
// different month).
|
||||||
|
if (state.calYear == null || state.calMonth == null) {
|
||||||
|
const now = new Date();
|
||||||
|
state.calYear = now.getFullYear();
|
||||||
|
state.calMonth = now.getMonth();
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
overlay.classList.add('open');
|
overlay.classList.add('open');
|
||||||
overlay.style.visibility = '';
|
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 { state, getChildren, CARD_COLORS } from './state.js';
|
||||||
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
|
||||||
import { pushCardEdit, snapshotCard } from './undo.js';
|
import { pushCardEdit, snapshotCard } from './undo.js';
|
||||||
|
import { showDialog } from './dialog.js';
|
||||||
|
|
||||||
const modal = $('modal');
|
const modal = $('modal');
|
||||||
const body = $('editor-body');
|
const body = $('editor-body');
|
||||||
@@ -238,7 +239,7 @@ export function openModal(card = null) {
|
|||||||
updateActiveLine();
|
updateActiveLine();
|
||||||
|
|
||||||
// Check for unsaved draft (newer than what we just loaded).
|
// 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);
|
const draft = loadDraft(card?.id);
|
||||||
if (draft && !window._testRunning) {
|
if (draft && !window._testRunning) {
|
||||||
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
const cardModified = card ? Date.parse(card.updated_at + 'Z') || 0 : 0;
|
||||||
@@ -248,18 +249,27 @@ export function openModal(card = null) {
|
|||||||
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
if (draftIsNewer && draftHasContent && differsFromLoaded) {
|
||||||
const age = Math.round((Date.now() - draft.savedAt) / 60000);
|
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 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?`);
|
const cardId = card?.id;
|
||||||
if (restore) {
|
// Async confirm — fires after openModal returns
|
||||||
inpTitle.value = draft.title || '';
|
(async () => {
|
||||||
body.value = draft.body || '';
|
const restore = await showDialog({
|
||||||
if (scratchpadBody) scratchpadBody.value = draft.scratchpad || '';
|
title: 'unsaved draft',
|
||||||
updatePreview();
|
message: `Unsaved draft from ${ageText} found.\n\nRestore it?`,
|
||||||
} else {
|
okLabel: 'Restore',
|
||||||
clearDraft(card?.id);
|
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) {
|
} else if (draft && window._testRunning) {
|
||||||
// During tests, silently discard any draft so it doesn't accumulate
|
|
||||||
clearDraft(card?.id);
|
clearDraft(card?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,8 +96,6 @@ function updatePreview() {
|
|||||||
set('--tp-bar-height', d.barHeight + 'px');
|
set('--tp-bar-height', d.barHeight + 'px');
|
||||||
set('--tp-pad-card', d.padCard + 'px');
|
set('--tp-pad-card', d.padCard + 'px');
|
||||||
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
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) {
|
function fillFontSelect(id, value) {
|
||||||
@@ -129,8 +127,6 @@ function syncControlsFromPending() {
|
|||||||
const valEl = $(valId);
|
const valEl = $(valId);
|
||||||
if (valEl) valEl.textContent = v;
|
if (valEl) valEl.textContent = v;
|
||||||
}
|
}
|
||||||
const scanEl = $('setting-scanlines');
|
|
||||||
if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitDesign() {
|
function commitDesign() {
|
||||||
@@ -167,16 +163,6 @@ function initDesignSettings() {
|
|||||||
if (designWired) return;
|
if (designWired) return;
|
||||||
designWired = true;
|
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
|
// Selects
|
||||||
for (const [id, key] of SELECT_FIELDS) {
|
for (const [id, key] of SELECT_FIELDS) {
|
||||||
const el = $(id);
|
const el = $(id);
|
||||||
|
|||||||
@@ -493,6 +493,25 @@ function collabSyncCards(PDO $db): array {
|
|||||||
'push' => $res['updated_at'] ?? $card['updated_at'],
|
'push' => $res['updated_at'] ?? $card['updated_at'],
|
||||||
]);
|
]);
|
||||||
$stats['pushed']++;
|
$stats['pushed']++;
|
||||||
|
|
||||||
|
// Push referenced local attachments to the bucket (encrypted,
|
||||||
|
// member-scoped). Extract `uploads/<hex>.<ext>` from the body.
|
||||||
|
$body = $card['body'] ?? '';
|
||||||
|
if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$localPath = UPLOAD_DIR . '/' . $fn;
|
||||||
|
if (!is_file($localPath)) continue;
|
||||||
|
$bytes = file_get_contents($localPath);
|
||||||
|
if ($bytes === false) continue;
|
||||||
|
$encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
|
||||||
|
if ($encoded === null) continue;
|
||||||
|
collabRelayCall('/api/collab-attachments', 'POST', [
|
||||||
|
'bucket_uid' => $card['bucket_uid'],
|
||||||
|
'filename' => $fn,
|
||||||
|
'data' => $encoded,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. Pull deltas ─────────────────────────────────────
|
// ── 3. Pull deltas ─────────────────────────────────────
|
||||||
@@ -628,6 +647,28 @@ function collabSyncCards(PDO $db): array {
|
|||||||
]);
|
]);
|
||||||
$stats['pulled']++;
|
$stats['pulled']++;
|
||||||
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
|
||||||
|
|
||||||
|
// Fetch missing attachments referenced in the body.
|
||||||
|
$pulledBody = $fields['body'] ?? '';
|
||||||
|
if ($pulledBody && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $pulledBody, $fm)) {
|
||||||
|
$uploadDir = UPLOAD_DIR;
|
||||||
|
if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
|
||||||
|
foreach (array_unique($fm[1]) as $fn) {
|
||||||
|
$dest = $uploadDir . '/' . $fn;
|
||||||
|
if (file_exists($dest)) continue; // already have it
|
||||||
|
$attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($c['bucket_uid']) . '&filename=' . urlencode($fn));
|
||||||
|
if (!($attRes['ok'] ?? false)) continue;
|
||||||
|
$atts = $attRes['attachments'] ?? [];
|
||||||
|
foreach ($atts as $a) {
|
||||||
|
if (($a['filename'] ?? '') !== $fn) continue;
|
||||||
|
$decoded = collabDecryptPayload($a['data'] ?? '');
|
||||||
|
if ($decoded && isset($decoded['raw'])) {
|
||||||
|
$bytes = base64_decode($decoded['raw']);
|
||||||
|
if ($bytes !== false) file_put_contents($dest, $bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Bump `collab_buckets.updated_at` for every bucket that received
|
// Bump `collab_buckets.updated_at` for every bucket that received
|
||||||
|
|||||||
@@ -58,4 +58,25 @@ describe('Calendar', () => {
|
|||||||
await sleep(150);
|
await sleep(150);
|
||||||
expect($('#calendar')).toBeHidden();
|
expect($('#calendar')).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves month across close/reopen', async () => {
|
||||||
|
// Open calendar and navigate forward 3 months
|
||||||
|
click('#calendar-btn');
|
||||||
|
await sleep(200);
|
||||||
|
click('#cal-next');
|
||||||
|
click('#cal-next');
|
||||||
|
click('#cal-next');
|
||||||
|
await sleep(200);
|
||||||
|
const navigatedTitle = $('#cal-title').textContent;
|
||||||
|
|
||||||
|
// Close
|
||||||
|
click('#cal-close');
|
||||||
|
await sleep(150);
|
||||||
|
expect($('#calendar')).toBeHidden();
|
||||||
|
|
||||||
|
// Reopen — must stay on the navigated month, not jump to today
|
||||||
|
click('#calendar-btn');
|
||||||
|
await sleep(200);
|
||||||
|
expect($('#cal-title').textContent).toBe(navigatedTitle);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -232,6 +232,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS collab_cards (
|
|||||||
)');
|
)');
|
||||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_cards_bucket_updated ON collab_cards(bucket_uid, updated_at)');
|
||||||
|
|
||||||
|
// Collab attachments: binary assets referenced by collab cards (images,
|
||||||
|
// PDFs, audio). Stored encrypted under the inter-tenant key. Scoped to
|
||||||
|
// a bucket — all members of the bucket can fetch. Filename is the
|
||||||
|
// hex-hash name that appears in card bodies as uploads/<name>.
|
||||||
|
$db->exec('CREATE TABLE IF NOT EXISTS collab_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bucket_uid TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (bucket_uid, filename)
|
||||||
|
)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_collab_att_bucket ON collab_attachments(bucket_uid, created_at)');
|
||||||
|
|
||||||
// Phase 4: advisory edit locks on collab cards. A client that opens the
|
// Phase 4: advisory edit locks on collab cards. A client that opens the
|
||||||
// editor for a collab card heartbeats into this table every 30s with a
|
// editor for a collab card heartbeats into this table every 30s with a
|
||||||
// 60s TTL. Other clients poll the list and show a lock indicator on the
|
// 60s TTL. Other clients poll the list and show a lock indicator on the
|
||||||
|
|||||||
@@ -199,3 +199,66 @@ if (preg_match('#^/api/collab-cards/([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)$#', $route
|
|||||||
]);
|
]);
|
||||||
jsonResponse(['ok' => true]);
|
jsonResponse(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Collab attachments (images/PDFs/audio referenced by collab cards) ─
|
||||||
|
|
||||||
|
if ($route === '/api/collab-attachments' && $method === 'POST') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$bucketUid = (string)($input['bucket_uid'] ?? '');
|
||||||
|
$filename = (string)($input['filename'] ?? '');
|
||||||
|
$data = (string)($input['data'] ?? '');
|
||||||
|
if (!$bucketUid || !$filename || !$data) jsonResponse(['error' => 'bucket_uid, filename, and data required'], 400);
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt-reencrypt hop: data arrives encrypted with sender's secret,
|
||||||
|
// stored at rest under the inter-tenant key.
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'sender has no client secret'], 400);
|
||||||
|
$senderSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$senderSecret) jsonResponse(['error' => 'sender secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
$plain = clientDecrypt($data, $senderSecret);
|
||||||
|
if ($plain === '') jsonResponse(['error' => 'attachment decrypt failed'], 400);
|
||||||
|
$stored = clientEncrypt($plain, $interKey);
|
||||||
|
|
||||||
|
$db->prepare('INSERT INTO collab_attachments (bucket_uid, filename, data) VALUES (:b, :f, :d)
|
||||||
|
ON CONFLICT(bucket_uid, filename) DO UPDATE SET data = excluded.data')
|
||||||
|
->execute(['b' => $bucketUid, 'f' => $filename, 'd' => $stored]);
|
||||||
|
jsonResponse(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($route === '/api/collab-attachments' && $method === 'GET') {
|
||||||
|
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||||
|
$bucketUid = trim($_GET['bucket_uid'] ?? '');
|
||||||
|
$filename = trim($_GET['filename'] ?? '');
|
||||||
|
if (!$bucketUid) jsonResponse(['error' => 'bucket_uid required'], 400);
|
||||||
|
if (!collabCardsIsMember($db, $bucketUid, (int)$clientTenant['id'])) {
|
||||||
|
jsonResponse(['error' => 'not a member of this bucket'], 403);
|
||||||
|
}
|
||||||
|
if (empty($clientTenant['secret_encrypted'])) jsonResponse(['error' => 'no client secret set'], 400);
|
||||||
|
$recipientSecret = masterDecrypt($clientTenant['secret_encrypted'], $MASTER_KEY);
|
||||||
|
if (!$recipientSecret) jsonResponse(['error' => 'recipient secret unavailable'], 500);
|
||||||
|
$interKey = getInterTenantKey($db, $MASTER_KEY);
|
||||||
|
if (!$interKey) jsonResponse(['error' => 'relay super-shared key not set'], 500);
|
||||||
|
|
||||||
|
if ($filename) {
|
||||||
|
$stmt = $db->prepare('SELECT filename, data FROM collab_attachments WHERE bucket_uid = :b AND filename = :f');
|
||||||
|
$stmt->execute(['b' => $bucketUid, 'f' => $filename]);
|
||||||
|
} else {
|
||||||
|
// List all filenames for this bucket (no data — client picks what to fetch)
|
||||||
|
$stmt = $db->prepare('SELECT filename FROM collab_attachments WHERE bucket_uid = :b ORDER BY created_at DESC');
|
||||||
|
$stmt->execute(['b' => $bucketUid]);
|
||||||
|
jsonResponse(['ok' => true, 'files' => array_column($stmt->fetchAll(), 'filename')]);
|
||||||
|
}
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$plain = clientDecrypt($row['data'] ?? '', $interKey);
|
||||||
|
$out[] = ['filename' => $row['filename'], 'data' => clientEncrypt($plain, $recipientSecret)];
|
||||||
|
}
|
||||||
|
jsonResponse(['ok' => true, 'attachments' => $out]);
|
||||||
|
}
|
||||||
+36
-4
@@ -171,6 +171,12 @@ class CalDavSync
|
|||||||
$time = $zm[1];
|
$time = $zm[1];
|
||||||
$timeEnd = $zm[2] ?? '';
|
$timeEnd = $zm[2] ?? '';
|
||||||
}
|
}
|
||||||
|
// Multi-day: **Bis:** 2026-04-20 or **Bis:** 2026-04-20 18:00
|
||||||
|
$endDate = ''; $endTime = '';
|
||||||
|
if (preg_match('/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}:\d{2}))?/i', $body, $bm)) {
|
||||||
|
$endDate = $bm[1];
|
||||||
|
$endTime = $bm[2] ?? '';
|
||||||
|
}
|
||||||
$location = '';
|
$location = '';
|
||||||
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
|
||||||
$description = '';
|
$description = '';
|
||||||
@@ -178,9 +184,19 @@ class CalDavSync
|
|||||||
|
|
||||||
// Build DTSTART/DTEND
|
// Build DTSTART/DTEND
|
||||||
$dateCompact = str_replace('-', '', $date);
|
$dateCompact = str_replace('-', '', $date);
|
||||||
|
$endDateCompact = $endDate ? str_replace('-', '', $endDate) : '';
|
||||||
if ($time) {
|
if ($time) {
|
||||||
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
|
$dtstart = $dateCompact . 'T' . str_replace(':', '', $time) . '00';
|
||||||
if ($timeEnd) {
|
if ($endDate) {
|
||||||
|
// Multi-day with time — use end date + end time (or start time + 1h)
|
||||||
|
$useEndTime = $endTime ?: $time;
|
||||||
|
if (!$endTime) {
|
||||||
|
$ts = strtotime($endDate . ' ' . $useEndTime);
|
||||||
|
$dtend = date('Ymd\THis', $ts + 3600);
|
||||||
|
} else {
|
||||||
|
$dtend = $endDateCompact . 'T' . str_replace(':', '', $endTime) . '00';
|
||||||
|
}
|
||||||
|
} elseif ($timeEnd) {
|
||||||
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
|
$dtend = $dateCompact . 'T' . str_replace(':', '', $timeEnd) . '00';
|
||||||
} else {
|
} else {
|
||||||
// Default: +1 hour
|
// Default: +1 hour
|
||||||
@@ -189,8 +205,13 @@ class CalDavSync
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$dtstart = $dateCompact;
|
$dtstart = $dateCompact;
|
||||||
// All-day: DTEND = next day
|
if ($endDate) {
|
||||||
$dtend = date('Ymd', strtotime($date . ' +1 day'));
|
// Multi-day all-day: DTEND is exclusive, so +1 day after end
|
||||||
|
$dtend = date('Ymd', strtotime($endDate . ' +1 day'));
|
||||||
|
} else {
|
||||||
|
// Single-day all-day: DTEND = next day
|
||||||
|
$dtend = date('Ymd', strtotime($date . ' +1 day'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
|
$uid = $card['caldav_uid'] ?: 'card-' . $card['id'] . '@hyprcrd';
|
||||||
@@ -224,12 +245,22 @@ class CalDavSync
|
|||||||
|
|
||||||
$time = '';
|
$time = '';
|
||||||
$timeEnd = '';
|
$timeEnd = '';
|
||||||
|
$endDate = '';
|
||||||
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
|
if (preg_match('/T(\d{2})(\d{2})/', $dt, $hm)) {
|
||||||
$time = "{$hm[1]}:{$hm[2]}";
|
$time = "{$hm[1]}:{$hm[2]}";
|
||||||
}
|
}
|
||||||
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
|
if (!empty($vevent['dtend']) && preg_match('/T(\d{2})(\d{2})/', $vevent['dtend'], $hm2)) {
|
||||||
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
|
$timeEnd = "{$hm2[1]}:{$hm2[2]}";
|
||||||
}
|
}
|
||||||
|
// Detect multi-day event: dtend's date > dtstart's date
|
||||||
|
if (!empty($vevent['dtend']) && preg_match('/^(\d{4})(\d{2})(\d{2})/', $vevent['dtend'], $edm)) {
|
||||||
|
$endIso = "{$edm[1]}-{$edm[2]}-{$edm[3]}";
|
||||||
|
// For all-day events, dtend is exclusive — subtract one day
|
||||||
|
if (!$time && !empty($vevent['dtend']) && strpos($vevent['dtend'], 'T') === false) {
|
||||||
|
$endIso = date('Y-m-d', strtotime($endIso . ' -1 day'));
|
||||||
|
}
|
||||||
|
if ($endIso > $date) $endDate = $endIso;
|
||||||
|
}
|
||||||
|
|
||||||
$summary = $vevent['summary'] ?? 'Termin';
|
$summary = $vevent['summary'] ?? 'Termin';
|
||||||
$title = $summary ? "{$date} — {$summary}" : $date;
|
$title = $summary ? "{$date} — {$summary}" : $date;
|
||||||
@@ -244,7 +275,8 @@ class CalDavSync
|
|||||||
}
|
}
|
||||||
|
|
||||||
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
|
$zeitLine = $time ? ($timeEnd ? "{$time}-{$timeEnd}" : $time) : '';
|
||||||
$body = "**Zeit:** {$zeitLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
|
$bisLine = $endDate ? ("\n**Bis:** " . $endDate . ($timeEnd && $endDate ? ' ' . $timeEnd : '')) : '';
|
||||||
|
$body = "**Zeit:** {$zeitLine}{$bisLine}\n**Ort:** " . ($vevent['location'] ?? '') . "\n**Teilnehmer:** \n\n---\n\n**Notizen:**\n" . ($vevent['description'] ?? '') . "\n\n{$tag}";
|
||||||
|
|
||||||
// Color: birthdays pink, others orange
|
// Color: birthdays pink, others orange
|
||||||
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
|
$defaultColor = $isBirthdayCal ? '#fb7185' : '#fb923c';
|
||||||
|
|||||||
Reference in new issue
Block a user