autosync toggle, configurable sync interval, 30-day session
- 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>
This commit is contained in:
1 parent
6cd00f9660
commit
b7f450e41f
6 files changed
+218
-8
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.8.2');
|
||||
define('APP_VERSION', '1.9.0');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.8.2",
|
||||
"version": "1.9.0",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1224,3 +1224,78 @@ body.anim-slow .board-card.card-pulse-local {
|
||||
.detail-stale-banner button:hover { background: var(--t-accent2); color: var(--t-bg); }
|
||||
.detail-stale-banner .detail-stale-dismiss { border-color: var(--t-border); color: var(--t-dim); padding: 3px 5px; }
|
||||
.detail-stale-banner .detail-stale-dismiss:hover { border-color: var(--t-danger); color: var(--t-danger); background: transparent; }
|
||||
|
||||
/* Sync + autosync combo: the pair reads as one button.
|
||||
- At rest: Sync has no right border, autosync has no left border —
|
||||
only the outer rectangle remains.
|
||||
- Hover Sync: the right border comes back in accent so Sync gets a
|
||||
complete outline around itself.
|
||||
- Hover autosync: no accent border at all — instead a background
|
||||
fill so the pair doesn't end up with a mismatched half-outline. */
|
||||
.header-sync-group { align-items: stretch; }
|
||||
.header-sync-group > button:first-child {
|
||||
border-right: 0 !important;
|
||||
}
|
||||
.header-sync-group > button:first-child:hover {
|
||||
border-right: 1px solid var(--t-accent) !important;
|
||||
}
|
||||
.header-sync-group > button + button {
|
||||
border-left: 0 !important;
|
||||
align-self: stretch;
|
||||
}
|
||||
#header-autosync:hover {
|
||||
background: var(--t-accent);
|
||||
border-color: var(--t-accent) !important;
|
||||
color: var(--t-bg) !important;
|
||||
}
|
||||
|
||||
/* Autosync toggle button.
|
||||
Border stays neutral in both states — the dot carries the status
|
||||
signal (accent + pulse when on, grey when off). Hover styling is
|
||||
handled by the bg-fill rule further down. */
|
||||
#header-autosync {
|
||||
border-color: var(--t-border);
|
||||
color: var(--t-muted);
|
||||
}
|
||||
|
||||
.header-autosync-dot {
|
||||
display: inline-block;
|
||||
width: 9px; height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-accent);
|
||||
transition: background 0.15s, transform 0.2s;
|
||||
}
|
||||
#header-autosync[data-autosync="off"] .header-autosync-dot {
|
||||
background: var(--t-dim);
|
||||
box-shadow: none;
|
||||
}
|
||||
/* Hover: button fills with solid accent (matching the Sync button's
|
||||
hover border color). Dot switches to the app background color so it
|
||||
reads as a knockout against the fill. Pulsing keeps tracking real
|
||||
status — when a sync fires during hover the ring still animates. */
|
||||
#header-autosync:hover .header-autosync-dot {
|
||||
background: var(--t-bg);
|
||||
}
|
||||
/* Pulse shadow is accent-colored too, which would be invisible against
|
||||
the accent fill. Swap it to a bg-toned ring during hover so the
|
||||
pulse stays visible. */
|
||||
#header-autosync:hover .header-autosync-dot.pulsing {
|
||||
animation: header-autosync-pulse-on-hover 0.9s ease-out 1;
|
||||
}
|
||||
@keyframes header-autosync-pulse-on-hover {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-bg) 75%, transparent); transform: scale(1); }
|
||||
40% { transform: scale(1.4); }
|
||||
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-bg) 0%, transparent); transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Burst pulse on each sync. JS adds `.pulsing` at the start of a sync
|
||||
and removes it after the animation finishes. Tuned to be visible
|
||||
against the button background — a generous ring that fades out. */
|
||||
.header-autosync-dot.pulsing {
|
||||
animation: header-autosync-pulse 0.9s ease-out 1;
|
||||
}
|
||||
@keyframes header-autosync-pulse {
|
||||
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--t-accent) 75%, transparent); transform: scale(1); }
|
||||
40% { transform: scale(1.4); }
|
||||
100% { box-shadow: 0 0 0 10px color-mix(in srgb, var(--t-accent) 0%, transparent); transform: scale(1); }
|
||||
}
|
||||
+26
-2
@@ -17,10 +17,20 @@ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
}
|
||||
|
||||
// ── Session ──
|
||||
//
|
||||
// 30-day persistence: single-user local-first app, no reason to log the
|
||||
// user out every 24 minutes (PHP's gc_maxlifetime default). Cookie +
|
||||
// server-side GC are set to the same duration so the session survives
|
||||
// reboots AND page reloads, up to a month of inactivity.
|
||||
$sessionLifetime = 60 * 60 * 24 * 30; // 30 days
|
||||
ini_set('session.gc_maxlifetime', (string)$sessionLifetime);
|
||||
ini_set('session.cookie_lifetime', (string)$sessionLifetime);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => $sessionLifetime,
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
'samesite' => 'Strict',
|
||||
'samesite' => 'Lax', // Strict was blocking some cross-link returns; Lax still blocks CSRF
|
||||
]);
|
||||
session_start();
|
||||
|
||||
@@ -1301,11 +1311,16 @@ if (str_starts_with($route, '/api/')) {
|
||||
<span class="text-[10px] text-t-dim font-pixel ml-1" data-app-version>v<?= APP_VERSION ?></span>
|
||||
<button id="search-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors ml-3">
|
||||
<i class="ph ph-magnifying-glass text-sm"></i><span class="font-pixel text-[10px]">Search</span>
|
||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 py-0.5 ml-1 font-pixel">ctrl+k</kbd>
|
||||
<kbd class="text-[9px] text-t-dim border border-t-border rounded px-1 ml-1 font-pixel leading-none" style="padding:1px 4px;">ctrl+k</kbd>
|
||||
</button>
|
||||
<div class="header-sync-group flex items-center">
|
||||
<button id="header-sync" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="sync now">
|
||||
<i class="ph ph-arrows-clockwise text-sm" id="header-sync-icon"></i><span class="font-pixel text-[10px]">Sync</span>
|
||||
</button>
|
||||
<button id="header-autosync" class="flex items-center justify-center px-2 py-1.5 text-xs border border-t-border transition-colors" data-tip="auto-sync">
|
||||
<span id="header-autosync-dot" class="header-autosync-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button id="calendar-btn" class="flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors">
|
||||
@@ -1815,6 +1830,15 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button id="relay-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plugs-connected text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
||||
</div>
|
||||
<div id="relay-status" class="hidden mt-1 px-3 py-2 text-[10px] font-mono break-words border border-t-border bg-t-bg"></div>
|
||||
<div class="border-t border-t-border pt-4 mt-4">
|
||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-timer text-xs"></i> sync frequency</div>
|
||||
<p class="text-[10px] text-t-dim leading-relaxed mb-2">How often the background sync polls the relay + CalDAV for new messages, collab updates and sync events. Lower = snappier, higher = less network traffic.</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-xs text-t-muted flex-1" for="setting-sync-interval">Poll every</label>
|
||||
<input id="setting-sync-interval" type="number" min="5" max="3600" step="1" class="bg-t-bg border border-t-border text-t-bright text-xs font-mono px-2 py-1 rounded-sm focus:border-t-accent focus:outline-none" style="width:72px;text-align:right;">
|
||||
<span class="text-xs text-t-dim">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-panel-behavior" class="hidden space-y-5">
|
||||
<div>
|
||||
|
||||
@@ -173,6 +173,7 @@ $('caldav-save')?.addEventListener('click', async () => {
|
||||
$('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');
|
||||
@@ -285,6 +286,19 @@ export async function loadRelaySettings() {
|
||||
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);
|
||||
@@ -350,6 +364,7 @@ function drainSyncEvents(relayRes) {
|
||||
async function backgroundSync() {
|
||||
if (state.bgSyncRunning) return;
|
||||
state.bgSyncRunning = true;
|
||||
pulseAutosyncDot();
|
||||
try {
|
||||
const headers = { 'X-CSRF-Token': getCsrf() };
|
||||
const [res, relayRes] = await Promise.all([
|
||||
@@ -369,13 +384,89 @@ async function backgroundSync() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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, 45000); // every 45 seconds
|
||||
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();
|
||||
@@ -27,7 +27,13 @@ let collabByUid = new Map();
|
||||
// card_id (number) -> true for cards with foreign edits we haven't opened yet
|
||||
let unreadCards = new Set();
|
||||
let pollHandle = null;
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
// The core CalDAV/relay sync cadence (Settings → Relay → Sync frequency,
|
||||
// default 15s) also drives the collab poll. Users can dial up or down
|
||||
// without touching code. Defaults + clamp mirror the core helper.
|
||||
function getPollIntervalMs() {
|
||||
const v = parseInt(localStorage.getItem('hyprcrd-sync-interval-sec'));
|
||||
return (Number.isFinite(v) && v >= 5 ? v : 15) * 1000;
|
||||
}
|
||||
|
||||
// Phase 4: edit locks (now exclusive — server-enforced)
|
||||
// card_uid -> { bucket_uid, holder_username, expires_at }
|
||||
@@ -69,6 +75,7 @@ async function pollOnce() {
|
||||
if (document.getElementById('modal')?.classList.contains('open')) return;
|
||||
try {
|
||||
pollLocked = true;
|
||||
hyprcrd.hooks?.emit?.('sync.pulse');
|
||||
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
||||
if (!res?.ok) return;
|
||||
if (res.self) window._collabSelf = res.self;
|
||||
@@ -166,12 +173,23 @@ function showQueuedToast(message) {
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
pollHandle = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||
pollHandle = setInterval(pollOnce, getPollIntervalMs());
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollHandle) { clearInterval(pollHandle); pollHandle = null; }
|
||||
}
|
||||
|
||||
// Restart when the user changes the sync interval in settings.
|
||||
hyprcrd.hooks?.on?.('sync.interval.changed', () => {
|
||||
if (pollHandle) startPolling();
|
||||
});
|
||||
|
||||
// Pause / resume alongside the header autosync toggle.
|
||||
hyprcrd.hooks?.on?.('autosync.changed', (on) => {
|
||||
if (on) startPolling();
|
||||
else stopPolling();
|
||||
});
|
||||
|
||||
// ── Bucket bar decoration ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -974,7 +992,9 @@ async function init() {
|
||||
decorateBucketBar();
|
||||
decorateBoardLocks();
|
||||
decorateBoardUnread();
|
||||
startPolling();
|
||||
// Respect the header autosync toggle — if the user has paused it,
|
||||
// skip the recurring poll entirely. Hook above restarts when toggled.
|
||||
if (localStorage.getItem('hyprcrd-autosync-enabled') !== '0') startPolling();
|
||||
}
|
||||
|
||||
// Defer init until the CSRF token is live — `checkAuth()` sets it async on
|
||||
|
||||
Reference in new issue
Block a user