// ── 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 = ` ${t.label} ${active ? '' : ''}`; 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'); const scan = $('theme-preview-scan'); if (scan) scan.style.display = String(d.scanlines) === '1' ? '' : '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; } const scanEl = $('setting-scanlines'); if (scanEl) scanEl.classList.toggle('on', String(designPending.scanlines) === '1'); } 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; // 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); 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; const cur = 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.value = cur; } 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(); } $('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; 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); 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');