- 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>
493 lines
20 KiB
JavaScript
493 lines
20 KiB
JavaScript
// ── Theme + Settings ──
|
|
|
|
import { $, api, getCsrf } from './util.js';
|
|
import { state } from './state.js';
|
|
import { renderSettingsPanel as renderNotifSettings } from './notifications.js';
|
|
|
|
const overlay = $('settings-overlay');
|
|
const themeList = $('settings-theme-list');
|
|
|
|
// Render theme list in settings
|
|
function renderThemes() {
|
|
if (!themeList) return;
|
|
const current = (designPending?.theme) || localStorage.getItem('hyprcrd-theme') || 'slate';
|
|
themeList.innerHTML = '';
|
|
for (const [key, t] of Object.entries(window.themes)) {
|
|
const btn = document.createElement('button');
|
|
const active = key === current;
|
|
btn.className = `flex items-center gap-3 px-3 py-2.5 text-left text-xs border transition-colors rounded ${active ? 'border-t-accent text-t-bright' : 'border-t-border text-t-muted hover:border-t-accent hover:text-t-bright'}`;
|
|
btn.innerHTML = `<span class="w-4 h-4 rounded-full shrink-0" style="background:${t.vars['--t-accent']}"></span>
|
|
<span class="font-pixel text-[10px] flex-1">${t.label}</span>
|
|
${active ? '<i class="ph ph-check text-t-accent text-sm"></i>' : ''}`;
|
|
btn.onclick = () => {
|
|
window.applyTheme(key);
|
|
if (designPending) {
|
|
designPending.theme = key;
|
|
window.saveDesign(designPending);
|
|
flashSaved();
|
|
}
|
|
renderThemes();
|
|
window._renderBoard?.();
|
|
};
|
|
themeList.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
// Tab switching
|
|
overlay?.querySelectorAll('.settings-tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
overlay.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const panel = tab.dataset.tab;
|
|
overlay.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden'));
|
|
$(`settings-panel-${panel}`)?.classList.remove('hidden');
|
|
if (panel === 'caldav') { window._loadCaldavSettings?.(); }
|
|
if (panel === 'relay') { window._loadRelaySettings?.(); }
|
|
if (panel === 'notifications') renderNotifSettings();
|
|
if (panel === 'behavior') initBehaviorSettings();
|
|
if (panel === 'theme') initDesignSettings();
|
|
if (panel === 'plugins') window._openPluginSettings?.();
|
|
if (panel !== 'theme') setPeekUI(false);
|
|
});
|
|
});
|
|
|
|
// ── Design (fonts, geometry, borders, spacing, effects, presets) ──
|
|
let designPending = null;
|
|
|
|
const SLIDER_FIELDS = [
|
|
['setting-radius-card', 'radiusCard', 'val-radius-card'],
|
|
['setting-radius-button', 'radiusButton', 'val-radius-button'],
|
|
['setting-radius-input', 'radiusInput', 'val-radius-input'],
|
|
['setting-radius-modal', 'radiusModal', 'val-radius-modal'],
|
|
['setting-radius-tag', 'radiusTag', 'val-radius-tag'],
|
|
['setting-radius-image', 'radiusImage', 'val-radius-image'],
|
|
['setting-border-card', 'borderCard', 'val-border-card'],
|
|
['setting-bar-height', 'barHeight', 'val-bar-height'],
|
|
['setting-pad-card', 'padCard', 'val-pad-card'],
|
|
['setting-gap-board', 'gapBoard', 'val-gap-board'],
|
|
['setting-lift-card', 'liftCard', 'val-lift-card'],
|
|
['setting-blur-overlay', 'blurOverlay', 'val-blur-overlay'],
|
|
];
|
|
const SELECT_FIELDS = [
|
|
['setting-font-headline', 'fontHeadline'],
|
|
['setting-font-content', 'fontContent'],
|
|
['setting-font-ui', 'fontUi'],
|
|
['setting-border-style', 'borderStyle'],
|
|
['setting-shadow-card', 'shadowCard'],
|
|
['setting-shadow-modal', 'shadowModal'],
|
|
['setting-shadow-ui', 'shadowUi'],
|
|
['setting-anim-speed', 'animSpeed'],
|
|
];
|
|
|
|
function updatePreview() {
|
|
const preview = $('theme-preview');
|
|
if (!preview || !designPending) return;
|
|
const d = designPending, fonts = window.themeFonts, shadows = window.designShadows;
|
|
const set = (k, v) => preview.style.setProperty(k, v);
|
|
set('--tp-font-headline', fonts[d.fontHeadline] || fonts.pixel);
|
|
set('--tp-font-content', fonts[d.fontContent] || fonts.mono);
|
|
set('--tp-font-ui', fonts[d.fontUi] || fonts.pixel);
|
|
set('--tp-radius-card', d.radiusCard + 'px');
|
|
set('--tp-radius-button', d.radiusButton + 'px');
|
|
set('--tp-radius-input', d.radiusInput + 'px');
|
|
set('--tp-radius-tag', d.radiusTag + 'px');
|
|
set('--tp-border-card', d.borderCard + 'px');
|
|
set('--tp-border-style', d.borderStyle);
|
|
set('--tp-bar-height', d.barHeight + 'px');
|
|
set('--tp-pad-card', d.padCard + 'px');
|
|
set('--tp-shadow-card', shadows[d.shadowCard] || 'none');
|
|
}
|
|
|
|
function fillFontSelect(id, value) {
|
|
const el = $(id);
|
|
if (!el) return;
|
|
el.innerHTML = '';
|
|
for (const [key, label] of Object.entries(window.themeFontLabels)) {
|
|
const opt = document.createElement('option');
|
|
opt.value = key; opt.textContent = label;
|
|
if (key === value) opt.selected = true;
|
|
el.appendChild(opt);
|
|
}
|
|
}
|
|
|
|
function syncControlsFromPending() {
|
|
if (!designPending) return;
|
|
fillFontSelect('setting-font-headline', designPending.fontHeadline);
|
|
fillFontSelect('setting-font-content', designPending.fontContent);
|
|
fillFontSelect('setting-font-ui', designPending.fontUi);
|
|
for (const [id, key] of SELECT_FIELDS) {
|
|
if (id.startsWith('setting-font-')) continue;
|
|
const el = $(id);
|
|
if (el) el.value = String(designPending[key] ?? '');
|
|
}
|
|
for (const [id, key, valId] of SLIDER_FIELDS) {
|
|
const v = String(designPending[key] ?? 0);
|
|
const el = $(id);
|
|
if (el) el.value = v;
|
|
const valEl = $(valId);
|
|
if (valEl) valEl.textContent = v;
|
|
}
|
|
}
|
|
|
|
function commitDesign() {
|
|
updatePreview();
|
|
window.applyDesign(designPending);
|
|
window.saveDesign(designPending);
|
|
flashSaved();
|
|
}
|
|
|
|
let saveFlashTimer = null;
|
|
function flashSaved() {
|
|
const status = $('design-status');
|
|
if (!status) return;
|
|
status.textContent = 'saved';
|
|
status.style.color = 'var(--t-accent)';
|
|
clearTimeout(saveFlashTimer);
|
|
saveFlashTimer = setTimeout(() => {
|
|
status.textContent = 'auto-saving';
|
|
status.style.color = 'var(--t-dim)';
|
|
}, 700);
|
|
}
|
|
|
|
let designWired = false;
|
|
|
|
function initDesignSettings() {
|
|
designPending = window.loadDesign();
|
|
syncControlsFromPending();
|
|
updatePreview();
|
|
refreshPresetSelect();
|
|
|
|
const status = $('design-status');
|
|
if (status) { status.textContent = 'auto-saving'; status.style.color = 'var(--t-dim)'; }
|
|
|
|
if (designWired) return;
|
|
designWired = true;
|
|
|
|
// Selects
|
|
for (const [id, key] of SELECT_FIELDS) {
|
|
const el = $(id);
|
|
if (!el) continue;
|
|
el.addEventListener('change', () => {
|
|
designPending[key] = el.value;
|
|
commitDesign();
|
|
});
|
|
}
|
|
|
|
// Sliders
|
|
for (const [id, key, valId] of SLIDER_FIELDS) {
|
|
const el = $(id);
|
|
if (!el) continue;
|
|
el.addEventListener('input', () => {
|
|
const num = Number(el.value);
|
|
designPending[key] = num;
|
|
const v = $(valId);
|
|
if (v) v.textContent = num;
|
|
commitDesign();
|
|
});
|
|
}
|
|
}
|
|
|
|
$('design-reset')?.addEventListener('click', () => {
|
|
designPending = { ...window.designDefaults };
|
|
syncControlsFromPending();
|
|
commitDesign();
|
|
renderThemes();
|
|
});
|
|
|
|
// ── Peek toggle (hide background dim so the real board is visible behind the popup) ──
|
|
function setPeekUI(peeking) {
|
|
document.body.classList.toggle('settings-peeking', peeking);
|
|
const sw = $('design-peek-toggle');
|
|
if (sw) sw.classList.toggle('on', peeking);
|
|
}
|
|
const peekRow = $('design-peek-row');
|
|
if (peekRow) peekRow.onclick = () => {
|
|
setPeekUI(!document.body.classList.contains('settings-peeking'));
|
|
};
|
|
|
|
// ── Presets ──
|
|
|
|
function loadUserPresets() {
|
|
try { return JSON.parse(localStorage.getItem('hyprcrd-design-presets') || '{}'); }
|
|
catch { return {}; }
|
|
}
|
|
function saveUserPresets(p) {
|
|
localStorage.setItem('hyprcrd-design-presets', JSON.stringify(p));
|
|
}
|
|
|
|
function refreshPresetSelect() {
|
|
const sel = $('design-preset-select');
|
|
if (!sel) return;
|
|
// localStorage wins over sel.value: the <select> survives settings
|
|
// close/reopen, so its lingering value would otherwise mask the
|
|
// persisted selection on the next refresh.
|
|
const cur = localStorage.getItem('hyprcrd-design-active-preset') || sel.value || '';
|
|
sel.innerHTML = '';
|
|
const builtinGroup = document.createElement('optgroup');
|
|
builtinGroup.label = 'built-in';
|
|
for (const name of Object.keys(window.builtinPresets || {})) {
|
|
const opt = document.createElement('option');
|
|
opt.value = 'builtin:' + name; opt.textContent = name;
|
|
builtinGroup.appendChild(opt);
|
|
}
|
|
sel.appendChild(builtinGroup);
|
|
const user = loadUserPresets();
|
|
const names = Object.keys(user);
|
|
if (names.length) {
|
|
const userGroup = document.createElement('optgroup');
|
|
userGroup.label = 'custom';
|
|
for (const name of names) {
|
|
const opt = document.createElement('option');
|
|
opt.value = 'user:' + name; opt.textContent = name;
|
|
userGroup.appendChild(opt);
|
|
}
|
|
sel.appendChild(userGroup);
|
|
}
|
|
if (cur && sel.querySelector(`option[value="${CSS.escape(cur)}"]`)) sel.value = cur;
|
|
// Disable delete button when a built-in preset is selected
|
|
const delBtn = $('design-preset-delete');
|
|
if (delBtn) {
|
|
const isUser = sel.value?.startsWith('user:');
|
|
delBtn.disabled = !isUser;
|
|
delBtn.style.opacity = isUser ? '' : '0.35';
|
|
delBtn.style.cursor = isUser ? '' : 'not-allowed';
|
|
}
|
|
}
|
|
|
|
function getSelectedPreset() {
|
|
const sel = $('design-preset-select');
|
|
if (!sel?.value) return null;
|
|
const [kind, ...rest] = sel.value.split(':');
|
|
const name = rest.join(':');
|
|
if (kind === 'builtin') return window.builtinPresets?.[name] ? { ...window.builtinPresets[name] } : null;
|
|
if (kind === 'user') return loadUserPresets()[name] ? { ...loadUserPresets()[name] } : null;
|
|
return null;
|
|
}
|
|
|
|
function loadSelectedPreset() {
|
|
const cfg = getSelectedPreset();
|
|
if (!cfg) return;
|
|
designPending = { ...window.designDefaults, ...cfg };
|
|
syncControlsFromPending();
|
|
commitDesign();
|
|
renderThemes();
|
|
const sel = $('design-preset-select');
|
|
if (sel?.value) localStorage.setItem('hyprcrd-design-active-preset', sel.value);
|
|
}
|
|
$('design-preset-select')?.addEventListener('change', loadSelectedPreset);
|
|
|
|
$('design-preset-save')?.addEventListener('click', () => {
|
|
const nameEl = $('design-preset-name');
|
|
const name = (nameEl?.value || '').trim();
|
|
if (!name) { nameEl?.focus(); return; }
|
|
const presets = loadUserPresets();
|
|
presets[name] = { ...designPending };
|
|
saveUserPresets(presets);
|
|
if (nameEl) nameEl.value = '';
|
|
refreshPresetSelect();
|
|
const sel = $('design-preset-select');
|
|
if (sel) sel.value = 'user:' + name;
|
|
localStorage.setItem('hyprcrd-design-active-preset', 'user:' + name);
|
|
const status = $('design-status');
|
|
if (status) { status.textContent = `preset "${name}" saved`; status.style.color = 'var(--t-accent)'; }
|
|
});
|
|
|
|
$('design-preset-delete')?.addEventListener('click', () => {
|
|
const sel = $('design-preset-select');
|
|
if (!sel?.value?.startsWith('user:')) return;
|
|
const name = sel.value.slice('user:'.length);
|
|
const presets = loadUserPresets();
|
|
delete presets[name];
|
|
saveUserPresets(presets);
|
|
if (localStorage.getItem('hyprcrd-design-active-preset') === 'user:' + name) {
|
|
localStorage.removeItem('hyprcrd-design-active-preset');
|
|
}
|
|
refreshPresetSelect();
|
|
});
|
|
|
|
$('design-preset-export')?.addEventListener('click', async () => {
|
|
const cfg = designPending || window.loadDesign();
|
|
const json = JSON.stringify(cfg, null, 2);
|
|
// Best-effort clipboard (silently fails on non-secure contexts)
|
|
let copied = false;
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(json);
|
|
copied = true;
|
|
}
|
|
} catch {}
|
|
// Always also trigger a file download — works regardless of clipboard support
|
|
try {
|
|
const blob = new Blob([json], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'hyprcrd-design.json';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (e) {
|
|
console.error('design export failed', e);
|
|
}
|
|
const status = $('design-status');
|
|
if (status) {
|
|
status.textContent = copied ? 'downloaded + copied to clipboard' : 'downloaded hyprcrd-design.json';
|
|
status.style.color = 'var(--t-accent)';
|
|
}
|
|
});
|
|
|
|
$('design-preset-import')?.addEventListener('click', async () => {
|
|
const { showDialog } = await import('./dialog.js');
|
|
const json = await showDialog({ title: 'Import design JSON', input: true, placeholder: '{"radiusCard":8,...}', okLabel: 'Import' });
|
|
if (!json) return;
|
|
try {
|
|
const cfg = JSON.parse(json);
|
|
designPending = { ...window.designDefaults, ...cfg };
|
|
syncControlsFromPending();
|
|
commitDesign();
|
|
renderThemes();
|
|
} catch {
|
|
const status = $('design-status');
|
|
if (status) { status.textContent = 'invalid JSON'; status.style.color = 'var(--t-danger)'; }
|
|
}
|
|
});
|
|
|
|
// Open/Close
|
|
function updateStats() {
|
|
const sc = $('stats-cards'); if (sc) sc.textContent = state.cards?.length || 0;
|
|
const sb = $('stats-buckets'); if (sb) sb.textContent = state.buckets?.length || 0;
|
|
const tags = new Set(); (state.cards || []).forEach(c => (c.tags || '').split(',').forEach(t => { if (t) tags.add(t); }));
|
|
const st = $('stats-tags'); if (st) st.textContent = tags.size;
|
|
}
|
|
|
|
// Vault key
|
|
$('settings-vault-save')?.addEventListener('click', async () => {
|
|
const key = $('settings-vault-key')?.value;
|
|
const status = $('settings-vault-status');
|
|
if (!key || key.length < 8) { if (status) { status.textContent = 'min 8 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
|
const res = await fetch('api/auth/vault-key', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
body: JSON.stringify({ key }),
|
|
}).then(r => r.json()).catch(() => ({ error: 'failed' }));
|
|
if (res.ok) {
|
|
if (status) { status.textContent = res.rekey ? 'saved — notes will re-encrypt on next sync' : 'saved'; status.style.color = 'var(--t-accent)'; }
|
|
$('settings-vault-key').value = '';
|
|
if (res.rekey) window._triggerSync?.();
|
|
} else {
|
|
if (status) { status.textContent = res.error || 'failed'; status.style.color = 'var(--t-danger)'; }
|
|
}
|
|
});
|
|
|
|
async function loadVaultStatus() {
|
|
const res = await api('api/auth/vault-key');
|
|
if (!res) return;
|
|
const status = $('settings-vault-status');
|
|
const input = $('settings-vault-key');
|
|
if (input) input.placeholder = res.has_key ? '(set — enter new to change)' : 'enter vault key';
|
|
if (status && res.has_key) { status.textContent = 'vault key is set'; status.style.color = 'var(--t-dim)'; }
|
|
}
|
|
|
|
$('settings-btn')?.addEventListener('click', () => {
|
|
renderThemes();
|
|
initDesignSettings();
|
|
updateStats();
|
|
loadVaultStatus();
|
|
overlay?.classList.add('open');
|
|
});
|
|
function closeSettings() {
|
|
overlay?.classList.remove('open');
|
|
setPeekUI(false);
|
|
}
|
|
$('settings-close')?.addEventListener('click', closeSettings);
|
|
overlay?.addEventListener('click', e => { if (e.target === overlay) closeSettings(); });
|
|
|
|
// Password change
|
|
$('settings-pw-save')?.addEventListener('click', async () => {
|
|
const current = $('settings-pw-current')?.value;
|
|
const newPw = $('settings-pw-new')?.value;
|
|
const confirm = $('settings-pw-confirm')?.value;
|
|
const status = $('settings-pw-status');
|
|
|
|
if (!current || !newPw) { if (status) { status.textContent = 'fill all fields'; status.style.color = 'var(--t-danger)'; } return; }
|
|
if (newPw !== confirm) { if (status) { status.textContent = 'passwords do not match'; status.style.color = 'var(--t-danger)'; } return; }
|
|
if (newPw.length < 4) { if (status) { status.textContent = 'min 4 characters'; status.style.color = 'var(--t-danger)'; } return; }
|
|
|
|
const res = await fetch('api/auth/password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
body: JSON.stringify({ current, new: newPw }),
|
|
}).then(r => r.json()).catch(() => ({ error: 'network error' }));
|
|
|
|
if (res.ok) {
|
|
if (status) { status.textContent = res.rekey ? 'password changed — re-encrypting notes...' : 'password changed'; status.style.color = 'var(--t-accent)'; }
|
|
$('settings-pw-current').value = '';
|
|
$('settings-pw-new').value = '';
|
|
$('settings-pw-confirm').value = '';
|
|
// Trigger sync to re-push notes with new encryption key
|
|
if (res.rekey) {
|
|
window._triggerSync?.();
|
|
setTimeout(() => { if (status) status.textContent = 'password changed, notes re-encrypted'; }, 3000);
|
|
}
|
|
} else {
|
|
if (status) { status.textContent = res.error || 'failed'; status.style.color = 'var(--t-danger)'; }
|
|
}
|
|
});
|
|
|
|
function initBehaviorSettings() {
|
|
const selects = {
|
|
'setting-current-days': { key: 'hyprcrd-current-days', def: '2' },
|
|
'setting-card-height': { key: 'hyprcrd-card-height', def: '180' },
|
|
'setting-card-width': { key: 'hyprcrd-card-width', def: '280' },
|
|
'setting-body-lines': { key: 'hyprcrd-body-lines', def: '5' },
|
|
'setting-click-action': { key: 'hyprcrd-click-action', def: 'preview' },
|
|
'setting-esc-action': { key: 'hyprcrd-esc-action', def: 'close' },
|
|
};
|
|
const toggles = {
|
|
'setting-current-include-synced': { key: 'hyprcrd-current-include-synced', def: '0' },
|
|
'setting-show-tags': { key: 'hyprcrd-show-tags', def: '1' },
|
|
'setting-show-thumbnails': { key: 'hyprcrd-show-thumbnails', def: '1' },
|
|
'setting-show-done-badge': { key: 'hyprcrd-show-done-badge', def: '1' },
|
|
'setting-confirm-delete': { key: 'hyprcrd-confirm-delete', def: '1' },
|
|
'setting-auto-save': { key: 'hyprcrd-auto-save', def: '0' },
|
|
};
|
|
for (const [id, cfg] of Object.entries(selects)) {
|
|
const el = $(id);
|
|
if (!el) continue;
|
|
el.value = localStorage.getItem(cfg.key) || cfg.def;
|
|
el.onchange = () => { localStorage.setItem(cfg.key, el.value); applyBehavior(); };
|
|
}
|
|
for (const [id, cfg] of Object.entries(toggles)) {
|
|
const el = $(id);
|
|
if (!el) continue;
|
|
const isOn = (localStorage.getItem(cfg.key) ?? cfg.def) === '1';
|
|
el.classList.toggle('on', isOn);
|
|
const row = el.closest('.toggle-row');
|
|
if (row) {
|
|
row.onclick = () => {
|
|
const nowOn = !el.classList.contains('on');
|
|
el.classList.toggle('on', nowOn);
|
|
localStorage.setItem(cfg.key, nowOn ? '1' : '0');
|
|
applyBehavior();
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyBehavior() {
|
|
const board = $('board');
|
|
if (board) {
|
|
const w = localStorage.getItem('hyprcrd-card-width') || '280';
|
|
board.style.gridTemplateColumns = `repeat(auto-fill, minmax(${w}px, 1fr))`;
|
|
}
|
|
document.documentElement.style.setProperty('--card-min-h', (localStorage.getItem('hyprcrd-card-height') || '180') + 'px');
|
|
document.documentElement.style.setProperty('--card-body-lines', localStorage.getItem('hyprcrd-body-lines') || '5');
|
|
window._renderBoard?.();
|
|
}
|
|
|
|
// Apply on load
|
|
applyBehavior();
|
|
|
|
// Apply saved theme on load
|
|
window.applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
|