New toggle under Settings → Behavior → Interactions: "Switch to card's bucket on open (search / link)". When enabled, openDetail calls activateBucket(card.bucket_id) so the board and bucket bar update to show the card's context. Skipped when already on 'all' or the correct bucket. Default off. Uses activateBucket() (from buckets.js) instead of raw state assignment so the bucket-bar highlight tracks the switch. Tests: two cases — setting on switches activeBucket + verifies the bucket-tab.active in the bar matches; setting off leaves the bucket unchanged. Both create/delete probe cards in try/finally and restore the setting + activeBucket in afterEach. Bumps to 1.9.27. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
494 lines
20 KiB
JavaScript
494 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' },
|
|
'setting-open-switches-bucket': { key: 'hyprcrd-open-switches-bucket', 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');
|