- header Sync button now has a paired autosync toggle to its right — reads as one button with no inner seam (outer border only). Dot inside pulses once per actual sync (bursts on every backgroundSync / triggerSync / collab pollOnce via a new sync.pulse hook) so the user can see polls happen. Dot + data-autosync attribute persist the enabled state in localStorage; paused state stops both the CalDAV/relay background timer and the collab poll via a broadcast autosync.changed hook. - hover polish: Sync regains a full accent border (the missing inner edge re-appears only on its hover); the toggle fills with solid accent as hover treatment so the pair never shows a half-outline. - sync interval moved out of hardcode into a settings field under Settings → Relay: number input, 5s..3600s, default 15s (was 45s on caldav / 30s on collab). Changes broadcast via sync.interval.changed and restart both timers without reload. - session cookie lifetime raised to 30 days with matching gc_maxlifetime and SameSite=Lax (was 0 / Strict — some users got kicked to login on every reload when gc cleaned up the /tmp/sess file or a cross-link return blocked the cookie). - search button's kbd element was pushing the button ~3px taller than Sync; leading-none + tighter padding puts them on the same height. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
473 lines
20 KiB
JavaScript
473 lines
20 KiB
JavaScript
// ── CalDAV Sync ──
|
|
|
|
import { $, esc, api, getCsrf } from './util.js';
|
|
import { state } from './state.js';
|
|
|
|
const CAL_ICONS = [
|
|
{ id: 'calendar-dots', label: 'calendar' },
|
|
{ id: 'briefcase', label: 'work' },
|
|
{ id: 'house', label: 'home' },
|
|
{ id: 'heart', label: 'personal' },
|
|
{ id: 'cake', label: 'birthday' },
|
|
{ id: 'graduation-cap', label: 'education' },
|
|
{ id: 'airplane', label: 'travel' },
|
|
{ id: 'basketball', label: 'sport' },
|
|
{ id: 'first-aid', label: 'health' },
|
|
{ id: 'star', label: 'important' },
|
|
{ id: 'users', label: 'team' },
|
|
{ id: 'phone', label: 'calls' },
|
|
];
|
|
const CAL_COLORS = ['#38bdf8', '#8b5cf6', '#fb7185', '#fbbf24', '#4ade80', '#fb923c', '#22d3ee', '#f472b6', '#a3e635', '#94a3b8'];
|
|
|
|
let calendarConfigs = {}; // slug → { color, icon }
|
|
const CAL_CONFIG_KEY = 'hyprcrd-cal-configs';
|
|
|
|
const settingsOverlay = $('settings-overlay');
|
|
const urlInput = $('caldav-url');
|
|
const userInput = $('caldav-user');
|
|
const passInput = $('caldav-pass');
|
|
const testStatus = $('caldav-test-status');
|
|
const syncStatus = $('caldav-sync-status');
|
|
const logEl = $('caldav-log');
|
|
|
|
export function openCaldavSettings() {
|
|
// Open settings overlay on sync tab
|
|
settingsOverlay?.classList.add('open');
|
|
settingsOverlay?.querySelectorAll('.settings-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === 'caldav'));
|
|
settingsOverlay?.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.toggle('hidden', p.id !== 'settings-panel-caldav'));
|
|
loadSettings();
|
|
}
|
|
|
|
export function closeCaldavSettings() {
|
|
settingsOverlay?.classList.remove('open');
|
|
}
|
|
|
|
function loadCalConfigs() {
|
|
try { calendarConfigs = JSON.parse(localStorage.getItem(CAL_CONFIG_KEY) || '{}'); } catch { calendarConfigs = {}; }
|
|
}
|
|
function saveCalConfigs() {
|
|
localStorage.setItem(CAL_CONFIG_KEY, JSON.stringify(calendarConfigs));
|
|
}
|
|
export function getCalConfig(slug) {
|
|
loadCalConfigs();
|
|
return calendarConfigs[slug] || null;
|
|
}
|
|
|
|
export function getAllCalConfigs() {
|
|
loadCalConfigs();
|
|
return calendarConfigs;
|
|
}
|
|
|
|
function renderCalList(calendars) {
|
|
const container = $('caldav-cal-list');
|
|
const wrap = $('caldav-calendars');
|
|
if (!container || !wrap || !calendars?.length) return;
|
|
loadCalConfigs();
|
|
wrap.classList.remove('hidden');
|
|
container.innerHTML = calendars.map(cal => {
|
|
const slug = cal.name.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
const cfg = calendarConfigs[slug] || {};
|
|
const activeColor = cfg.color || '#94a3b8';
|
|
const activeIcon = cfg.icon || 'calendar-dots';
|
|
return `<div class="flex items-center gap-2 py-1" data-cal-slug="${slug}">
|
|
<span style="width:10px;height:10px;border-radius:2px;background:${activeColor};flex-shrink:0;"></span>
|
|
<span class="font-pixel text-[10px] text-t-bright flex-1">${esc(cal.name)}</span>
|
|
<div class="flex gap-1">
|
|
${CAL_COLORS.map(c => `<button class="cal-cfg-color" data-color="${c}" style="width:14px;height:14px;border-radius:2px;background:${c};border:2px solid ${c === activeColor ? 'var(--t-bright)' : 'transparent'};cursor:pointer;"></button>`).join('')}
|
|
</div>
|
|
<div class="flex gap-1 ml-1">
|
|
${CAL_ICONS.slice(0, 6).map(ic => `<button class="cal-cfg-icon" data-icon="${ic.id}" data-tip="${ic.label}" style="font-size:12px;cursor:pointer;padding:1px;border-radius:2px;${ic.id === activeIcon ? 'color:var(--t-accent);' : 'color:var(--t-dim);'}"><i class="ph ph-${ic.id}"></i></button>`).join('')}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.cal-cfg-color').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const slug = btn.closest('[data-cal-slug]').dataset.calSlug;
|
|
if (!calendarConfigs[slug]) calendarConfigs[slug] = {};
|
|
calendarConfigs[slug].color = btn.dataset.color;
|
|
saveCalConfigs();
|
|
renderCalList(calendars);
|
|
});
|
|
});
|
|
container.querySelectorAll('.cal-cfg-icon').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const slug = btn.closest('[data-cal-slug]').dataset.calSlug;
|
|
if (!calendarConfigs[slug]) calendarConfigs[slug] = {};
|
|
calendarConfigs[slug].icon = btn.dataset.icon;
|
|
saveCalConfigs();
|
|
renderCalList(calendars);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function loadSettings() {
|
|
const s = await api('api/caldav/settings');
|
|
if (!s) return;
|
|
if (urlInput) urlInput.value = s.url || '';
|
|
if (userInput) userInput.value = s.username || '';
|
|
if (passInput) passInput.value = '';
|
|
if (passInput && s.has_password) passInput.placeholder = '(saved)';
|
|
if (syncStatus) syncStatus.textContent = s.last_sync ? `last sync: ${s.last_sync}` : '';
|
|
// Load calendar list if configured
|
|
if (s.url && s.has_password) {
|
|
const test = await fetch('api/caldav/test', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
body: '{}',
|
|
}).then(r => r.json()).catch(() => null);
|
|
if (test?.calendars) renderCalList(test.calendars);
|
|
}
|
|
}
|
|
|
|
|
|
function setCaldavTestStatus(text, kind = 'info') {
|
|
if (!testStatus) return;
|
|
if (!text) {
|
|
testStatus.classList.add('hidden');
|
|
testStatus.textContent = '';
|
|
testStatus.style.borderColor = '';
|
|
testStatus.style.color = '';
|
|
return;
|
|
}
|
|
testStatus.classList.remove('hidden');
|
|
testStatus.textContent = text;
|
|
if (kind === 'error') {
|
|
testStatus.style.borderColor = 'var(--t-danger)';
|
|
testStatus.style.color = 'var(--t-danger)';
|
|
} else if (kind === 'success') {
|
|
testStatus.style.borderColor = 'var(--t-accent)';
|
|
testStatus.style.color = 'var(--t-accent)';
|
|
} else {
|
|
testStatus.style.borderColor = 'var(--t-border)';
|
|
testStatus.style.color = 'var(--t-muted)';
|
|
}
|
|
}
|
|
|
|
$('caldav-test')?.addEventListener('click', async () => {
|
|
setCaldavTestStatus('testing...');
|
|
const body = { url: urlInput.value, username: userInput.value };
|
|
if (passInput.value) body.password = passInput.value;
|
|
const res = await fetch('api/caldav/test', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
body: JSON.stringify(body),
|
|
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
|
|
|
if (res.ok) {
|
|
const names = (res.calendars || []).map(c => c.name).join(', ');
|
|
setCaldavTestStatus(`connected (${names || 'no calendars'})`, 'success');
|
|
if (res.calendars) renderCalList(res.calendars);
|
|
} else {
|
|
setCaldavTestStatus(res.error || 'failed', 'error');
|
|
}
|
|
showConnectionLog(res.log);
|
|
});
|
|
|
|
$('caldav-save')?.addEventListener('click', async () => {
|
|
const body = { url: urlInput.value, username: userInput.value, enabled: true };
|
|
if (passInput.value) body.password = passInput.value;
|
|
await api('api/caldav/settings', { method: 'POST', body });
|
|
testStatus.textContent = 'saved';
|
|
testStatus.style.color = 'var(--t-accent)';
|
|
});
|
|
|
|
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
|
|
|
export async function triggerSync() {
|
|
pulseAutosyncDot();
|
|
const btn = $('cal-sync');
|
|
if (btn) btn.style.opacity = '0.5';
|
|
const headerIcon = $('header-sync-icon');
|
|
if (headerIcon) headerIcon.classList.add('card-spinner');
|
|
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
|
|
|
const res = await fetch('api/caldav/sync', {
|
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
|
|
|
if (btn) btn.style.opacity = '';
|
|
|
|
// Also poll relay
|
|
// (header spinner stays on until the relay poll completes too — see end)
|
|
const relayRes = await fetch('api/relay/poll', {
|
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
drainSyncEvents(relayRes);
|
|
|
|
if (res.ok) {
|
|
const s = res.stats || {};
|
|
const rp = relayRes?.pulled || 0;
|
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
const allLog = res.log || [];
|
|
const hasErrors = allLog.some(l => l.status === 'error');
|
|
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
|
|
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
|
await window._reloadAll?.();
|
|
showLog(allLog);
|
|
showSyncNotification(total);
|
|
} else {
|
|
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
|
}
|
|
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
|
}
|
|
|
|
function showLog(log) {
|
|
if (!logEl || !log?.length) return;
|
|
logEl.classList.remove('hidden');
|
|
logEl.innerHTML = log.map(l =>
|
|
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
|
).join('');
|
|
}
|
|
|
|
function showConnectionLog(lines) {
|
|
if (!logEl || !lines?.length) return;
|
|
logEl.classList.remove('hidden');
|
|
logEl.innerHTML = lines.map(l =>
|
|
`<div class="${l.startsWith('ERROR') || l.startsWith('EXCEPTION') ? 'text-t-danger' : l.startsWith('OK') ? 'text-t-accent' : 'text-t-dim'}">${l.replace(/</g, '<')}</div>`
|
|
).join('');
|
|
}
|
|
|
|
// ── Relay settings ──
|
|
|
|
function setRelayStatus(text, kind = 'info') {
|
|
const el = $('relay-status');
|
|
if (!el) return;
|
|
if (!text) {
|
|
el.classList.add('hidden');
|
|
el.textContent = '';
|
|
el.style.borderColor = '';
|
|
el.style.color = '';
|
|
return;
|
|
}
|
|
el.classList.remove('hidden');
|
|
el.textContent = text;
|
|
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
|
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
|
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
|
}
|
|
|
|
$('relay-save')?.addEventListener('click', async () => {
|
|
const body = {
|
|
url: $('relay-url')?.value || '',
|
|
username: $('relay-username')?.value || '',
|
|
key: $('relay-key')?.value || '',
|
|
};
|
|
setRelayStatus('saving...');
|
|
await api('api/relay/settings', { method: 'POST', body });
|
|
setRelayStatus('saved', 'success');
|
|
setTimeout(() => setRelayStatus(''), 1500);
|
|
});
|
|
|
|
$('relay-test')?.addEventListener('click', async () => {
|
|
setRelayStatus('testing...');
|
|
const res = await fetch('api/relay/poll', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
body: JSON.stringify({ strict: true }),
|
|
}).then(r => r.json()).catch(() => null);
|
|
if (res?.ok) {
|
|
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
|
if (res.pulled) await window._reloadAll?.();
|
|
drainSyncEvents(res);
|
|
} else {
|
|
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
|
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
|
}
|
|
});
|
|
|
|
export async function loadRelaySettings() {
|
|
const s = await api('api/relay/settings');
|
|
if (!s) return;
|
|
const urlInput = $('relay-url');
|
|
const userInput = $('relay-username');
|
|
const keyInput = $('relay-key');
|
|
if (urlInput) urlInput.value = s.url || '';
|
|
if (userInput) userInput.value = s.username || '';
|
|
if (keyInput) {
|
|
keyInput.value = s.key || '';
|
|
keyInput.placeholder = 'shared secret';
|
|
}
|
|
// Sync-interval number input (shared setting, persisted in localStorage).
|
|
// `change` commits on blur or Enter; `input` would save on every keystroke
|
|
// and restart the timer on each digit typed — noisy and pointless.
|
|
const intervalInp = $('setting-sync-interval');
|
|
if (intervalInp) {
|
|
intervalInp.value = String(getSyncIntervalSec());
|
|
intervalInp.onchange = () => {
|
|
setSyncIntervalSec(intervalInp.value);
|
|
// Reflect the clamped value back so users see when we refused a 2
|
|
// or 5000.
|
|
intervalInp.value = String(getSyncIntervalSec());
|
|
};
|
|
}
|
|
}
|
|
|
|
$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
|
|
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
|
$('header-sync')?.addEventListener('click', () => triggerSync());
|
|
|
|
// Auto-sync throttle
|
|
let lastAutoSync = 0;
|
|
export function autoSync() {
|
|
if (window._testRunning) return;
|
|
const now = Date.now();
|
|
if (now - lastAutoSync < 60000) return;
|
|
lastAutoSync = now;
|
|
triggerSync();
|
|
}
|
|
|
|
// Background sync every 2 minutes
|
|
function showSyncNotification(stats) {
|
|
const s = stats || {};
|
|
const parts = [];
|
|
if (s.pulled) parts.push(`${s.pulled} received`);
|
|
if (s.pushed) parts.push(`${s.pushed} sent`);
|
|
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
|
if (!parts.length) return;
|
|
|
|
const note = document.createElement('div');
|
|
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
|
document.body.appendChild(note);
|
|
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
|
}
|
|
|
|
// One toast per incoming sync event: "alice synced: Card Title".
|
|
// Stacks downward so a burst of multiple doesn't overlay each other.
|
|
let syncEventToastOffset = 0;
|
|
function showSyncEventToast(ev) {
|
|
const body = String(ev?.body || '').trim();
|
|
if (!body) return;
|
|
const bottom = 110 + syncEventToastOffset * 48;
|
|
syncEventToastOffset++;
|
|
const note = document.createElement('div');
|
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
|
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</div>`;
|
|
document.body.appendChild(note);
|
|
setTimeout(() => {
|
|
note.style.transition = 'opacity 0.3s';
|
|
note.style.opacity = '0';
|
|
setTimeout(() => {
|
|
note.remove();
|
|
syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
|
|
}, 350);
|
|
}, 5000);
|
|
}
|
|
|
|
function drainSyncEvents(relayRes) {
|
|
if (relayRes?.sync_debug) {
|
|
console.debug('[sync-events]', relayRes.sync_debug);
|
|
}
|
|
const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
|
|
for (const ev of events) showSyncEventToast(ev);
|
|
}
|
|
|
|
async function backgroundSync() {
|
|
if (state.bgSyncRunning) return;
|
|
state.bgSyncRunning = true;
|
|
pulseAutosyncDot();
|
|
try {
|
|
const headers = { 'X-CSRF-Token': getCsrf() };
|
|
const [res, relayRes] = await Promise.all([
|
|
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
]);
|
|
const s = res?.stats || {};
|
|
const rp = relayRes?.pulled || 0;
|
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
|
await window._reloadAll?.();
|
|
showSyncNotification(total);
|
|
}
|
|
drainSyncEvents(relayRes);
|
|
} catch {} finally {
|
|
state.bgSyncRunning = false;
|
|
}
|
|
}
|
|
|
|
// ── Configurable sync interval ────────────────────────────────
|
|
// One setting drives both the CalDAV/relay background sync and the
|
|
// collab-buckets plugin poll. Stored in localStorage as seconds.
|
|
// Changed via the Relay settings panel → emits `sync.interval.changed`
|
|
// so every poller can restart with the new cadence without reload.
|
|
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
|
|
const SYNC_INTERVAL_DEFAULT = 15; // snappy default — tune up on slow machines or paid relays
|
|
const SYNC_INTERVAL_MIN = 5;
|
|
const SYNC_INTERVAL_MAX = 3600;
|
|
export function getSyncIntervalSec() {
|
|
const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
|
|
return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
|
|
}
|
|
export function setSyncIntervalSec(sec) {
|
|
const n = parseInt(sec);
|
|
const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
|
|
localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
|
|
window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
|
|
}
|
|
|
|
// ── Autosync on/off toggle ────────────────────────────────────
|
|
// User can pause background polling from the header (play/pause icon
|
|
// next to the Sync button). Setting persists in localStorage.
|
|
const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
|
|
export function isAutoSyncEnabled() {
|
|
return localStorage.getItem(AUTOSYNC_KEY) !== '0';
|
|
}
|
|
export function setAutoSyncEnabled(on) {
|
|
localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
|
|
if (on) startBackgroundSync();
|
|
else stopBackgroundSync();
|
|
updateAutosyncButton();
|
|
// Broadcast so collab-buckets (and any future pollers) pause alongside.
|
|
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
|
|
}
|
|
|
|
// Start background sync after app is ready
|
|
let bgSyncInterval = null;
|
|
export function startBackgroundSync() {
|
|
if (bgSyncInterval) return;
|
|
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
|
|
backgroundSync();
|
|
bgSyncInterval = setInterval(backgroundSync, getSyncIntervalSec() * 1000);
|
|
updateAutosyncButton();
|
|
}
|
|
export function stopBackgroundSync() {
|
|
if (bgSyncInterval) { clearInterval(bgSyncInterval); bgSyncInterval = null; }
|
|
updateAutosyncButton();
|
|
}
|
|
|
|
// Restart the background timer when the user changes the interval.
|
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
|
|
if (bgSyncInterval) {
|
|
clearInterval(bgSyncInterval);
|
|
bgSyncInterval = setInterval(backgroundSync, getSyncIntervalSec() * 1000);
|
|
}
|
|
});
|
|
|
|
// ── Header autosync button ────────────────────────────────────
|
|
// Accent dot when on, grey when off; a single burst-pulse fires at the
|
|
// start of every actual sync so the user sees the poll happen.
|
|
function updateAutosyncButton() {
|
|
const btn = $('header-autosync');
|
|
if (!btn) return;
|
|
const on = isAutoSyncEnabled();
|
|
btn.dataset.autosync = on ? 'on' : 'off';
|
|
btn.setAttribute('data-tip', on
|
|
? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
|
|
: 'auto-sync: paused — click to resume');
|
|
}
|
|
|
|
// Pulse the dot once; retriggers cleanly even if called mid-animation by
|
|
// ripping the class and forcing a reflow before re-adding.
|
|
export function pulseAutosyncDot() {
|
|
const dot = $('header-autosync-dot');
|
|
if (!dot) return;
|
|
dot.classList.remove('pulsing');
|
|
void dot.offsetWidth;
|
|
dot.classList.add('pulsing');
|
|
}
|
|
// Also accept pulses from other pollers (collab-buckets, etc.) via hooks.
|
|
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
|
|
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
|
|
// Also refresh tooltip when interval changes
|
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
|
|
updateAutosyncButton();
|