From c5bd7e38bfeead45a8bbd8bab130064a6c9b9a24 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Mon, 13 Jul 2026 20:49:05 +1000 Subject: [PATCH] feat: add reminders, auto-delete, cross-device sync, dark mode, and rediscovery spotlight - Rename extension from Shelf to "Read it later" - Add reminder settings: highlight and pin overdue unarchived items - Add auto-delete with a hard safety rule requiring its period to exceed the reminder period - Add cross-device sync via chrome.storage.sync (lightweight per-item metadata, no thumbnails) - Add light/dark/system theme support - Fix list-view Archive/Delete button alignment (card-perf wasn't filling the row) - Add "Worth a second look" spotlight: proactively resurfaces old, forgotten items with automatic backoff so repeatedly-ignored items are shown less often, never permanently --- README.md | 65 ++++++++++-- background.js | 54 +++++++++- manifest.json | 8 +- newtab.css | 205 +++++++++++++++++++++++++++++++++++-- newtab.html | 67 +++++++++++- newtab.js | 275 ++++++++++++++++++++++++++++++++++++++++++++++++-- sync.js | 205 +++++++++++++++++++++++++++++++++++++ 7 files changed, 847 insertions(+), 32 deletions(-) create mode 100644 sync.js diff --git a/README.md b/README.md index 7a6c394..50648c9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Shelf — Read Later +# Read it later A new-tab page that shows everything you've saved to read later, as a sortable grid or list. No account, no server — everything lives in your @@ -26,12 +26,48 @@ Thumbnails are captured automatically when you save the *current* page (a screenshot of what's on screen). Links saved via right-click show a favicon instead, since the browser hasn't loaded that page yet. +## Reminders and auto-delete + +The gear icon opens a settings panel (also home to the light/dark/system +theme picker) with two optional timers, both off by default: + +- **Remind me after** — items older than this get pinned to the top of the + shelf and highlighted, so they don't get buried. +- **Delete unarchived items after** — silently removes items you never + came back to. Archiving an item takes it out of consideration entirely. + +The delete period must be longer than the reminder period — enforced both +in the settings panel (an inline error if you try to set it shorter) and as +a hard rule in the deletion logic itself, so you're always warned before +anything is removed. Auto-delete runs on an hourly background alarm (so it +still works even if you never open the new tab page), and again whenever +the shelf page is open; when the shelf page does the deleting, it shows a +courtesy toast with an **Undo**. + +## Worth a second look + +Above the shelf, a small strip proactively surfaces 2–3 old, forgotten, +unarchived items each time you open a new tab — on by default, toggleable +in the settings panel. Unlike a notification, it doesn't nag: the more +times an item gets shown without you opening/archiving/deleting it, the +less often it's picked again (backing off from every few days out to every +couple of months), so nothing gets shown forever, but nothing gets shown +*so* rarely that it's effectively forgotten again either. Archiving or +deleting an item removes it from the strip immediately with no replacement +until the next refresh — no whack-a-mole. + +"Old enough to matter" uses the same period as the reminder setting above +when reminders are on, or a 14-day default when they're off. It's fine (and +expected) for the same item to show up both pinned/highlighted in the main +shelf *and* in this strip at once — they're not trying to be non-overlapping, +just two different nudges. + ## Install — Chrome / Edge (unpacked) 1. Go to `chrome://extensions` (or `edge://extensions`). 2. Turn on **Developer mode** (top right). 3. Click **Load unpacked** and select this folder. -4. Open a new tab — you should see the Shelf page. +4. Open a new tab — you should see the Read it later page. ## Install — Firefox (temporary, for testing) @@ -47,13 +83,26 @@ favicon instead, since the browser hasn't loaded that page yet. > [Developer Edition / Nightly build](https://www.mozilla.org/firefox/developer/) > with `xpinstall.signatures.required` set to `false`. +## Syncing across devices + +Read it later keeps full items (including screenshot thumbnails) in +`chrome.storage.local`, and also mirrors a lightweight copy of each item +(url, title, domain, favicon, dates, archived state, spotlight backoff +counters — no images) to +`chrome.storage.sync`, one entry per item so each stays well under the +8KB-per-item sync limit. On another device signed into the same browser +account, opening the shelf pulls those in automatically; thumbnails just +aren't part of what syncs, so items saved elsewhere show a favicon instead +until re-saved on that device. Archiving and deleting also sync. This is +all best-effort — offline, not signed in, or over the (~100KB / 512-item) +sync quota just means that device keeps working from local storage alone. + +Note: `chrome.storage.sync` needs a stable extension ID to work, which +only applies to a permanently installed, signed add-on — not a Firefox +"temporary add-on" loaded via `about:debugging` (see install notes above), +which won't persist sync identity across restarts. + ## Notes / things you might want to change -- Storage uses `chrome.storage.local`, so saved links stay on the device - you saved them on. Swapping the `chrome.storage.local` calls for - `chrome.storage.sync` in `background.js` and `newtab.js` would sync - small link lists across your signed-in browsers, but `sync` storage - has a much smaller quota (~100KB) and doesn't handle thumbnails well — - not recommended once you have images in the mix. - Icons in `icons/` are placeholders — swap them for your own artwork any time; sizes 16/32/48/128 are already wired up in `manifest.json`. diff --git a/background.js b/background.js index adff3c5..5d0028b 100644 --- a/background.js +++ b/background.js @@ -1,8 +1,17 @@ -// Shelf — background service worker +// Read it later — background service worker // Works under both the `chrome` namespace (Chrome/Edge) and Firefox's // chrome.* alias for WebExtensions. +// Chrome loads only "service_worker" from the manifest (a Worker context, so +// importScripts works); Firefox loads the "scripts" array instead, which +// already includes sync.js ahead of this file in the same global scope. +if (typeof importScripts === "function") { + importScripts("sync.js"); +} + const STORAGE_KEY = "shelfItems"; +const PREFS_KEY = "shelfPrefs"; +const AUTO_DELETE_ALARM = "autoDeleteSweep"; function domainOf(url) { try { @@ -21,25 +30,51 @@ async function setItems(items) { await chrome.storage.local.set({ [STORAGE_KEY]: items }); } +async function getPrefs() { + const data = await chrome.storage.local.get(PREFS_KEY); + return data[PREFS_KEY] || {}; +} + +// Runs on an hourly alarm so overdue items get cleaned up even when the +// shelf page is never opened. Silent — no one's necessarily watching, so +// unlike the newtab-page sweep there's no toast/undo here; the newtab page +// runs its own courtesy-toast sweep whenever it's open. +async function sweepAutoDelete() { + const prefs = await getPrefs(); + if (!prefs.autoDeleteEnabled) return; + + const items = await getItems(); + const remaining = items.filter((item) => !isOverdueForDeletion(item, prefs)); + if (remaining.length === items.length) return; + + const merged = typeof reconcileWithSync === "function" ? await reconcileWithSync(remaining) : remaining; + await setItems(merged); +} + async function addItem({ url, title, favIconUrl, image }) { if (!url) return; - const items = await getItems(); + let items = await getItems(); // De-dupe: if already saved, just bump it to the top and refresh metadata. const existingIndex = items.findIndex((i) => i.url === url); + const now = Date.now(); const entry = { - id: existingIndex >= 0 ? items[existingIndex].id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + id: existingIndex >= 0 ? items[existingIndex].id : `${now}-${Math.random().toString(36).slice(2, 8)}`, url, title: title || url, domain: domainOf(url), favicon: favIconUrl || "", image: image || (existingIndex >= 0 ? items[existingIndex].image : ""), - dateAdded: Date.now(), + dateAdded: now, archived: false, + updatedAt: now, + surfaceCount: existingIndex >= 0 ? items[existingIndex].surfaceCount || 0 : 0, + lastSurfacedAt: existingIndex >= 0 ? items[existingIndex].lastSurfacedAt || 0 : 0, }; if (existingIndex >= 0) items.splice(existingIndex, 1); items.unshift(entry); + items = typeof reconcileWithSync === "function" ? await reconcileWithSync(items) : items; await setItems(items); return entry; } @@ -111,3 +146,14 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { if (tab && tab.id) flashBadge(tab.id, "✓"); } }); + +// --- Auto-delete: hourly sweep, independent of whether the shelf is open --- +// chrome.alarms.create is idempotent (re-arming an existing alarm just +// resets it), so it's safe to call unconditionally every time this script +// runs rather than only from onInstalled. +chrome.alarms.create(AUTO_DELETE_ALARM, { periodInMinutes: 60 }); +sweepAutoDelete(); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === AUTO_DELETE_ALARM) sweepAutoDelete(); +}); diff --git a/manifest.json b/manifest.json index 81a060c..2266b83 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "manifest_version": 3, - "name": "Shelf — Read Later", + "name": "Read it later", "version": "1.0.0", "description": "Save pages and links for later, and see them all as a sortable shelf every time you open a new tab.", "icons": { @@ -9,13 +9,13 @@ "48": "icons/icon48.png", "128": "icons/icon128.png" }, - "permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage"], + "permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage", "alarms"], "background": { "service_worker": "background.js", - "scripts": ["background.js"] + "scripts": ["sync.js", "background.js"] }, "action": { - "default_title": "Save this page to Shelf", + "default_title": "Save this page to Read it later", "default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png", diff --git a/newtab.css b/newtab.css index e34a898..ff4aa40 100644 --- a/newtab.css +++ b/newtab.css @@ -4,10 +4,66 @@ --ink: #211E19; --muted: #756E60; --hairline: #DBD6C7; + --hairline-strong: #C9C2AE; --clay: #C47A4A; --clay-deep: #A8623A; --moss: #55684B; + --accent-soft: #FBEFE6; + --muted-soft: #E6E2D3; + --thumb-1: #E4DFD0; + --thumb-2: #D8D2BF; + --danger: #A8412D; + --danger-soft: #E3B7A9; + --toast-accent: #E2905F; --shadow: 0 1px 2px rgba(33, 30, 25, 0.06), 0 6px 16px rgba(33, 30, 25, 0.05); + color-scheme: light; +} + +/* Dark palette: applied when the user forces dark mode ([data-theme="dark"]), + or leaves it on "system" (anything but [data-theme="light"]) and the OS + prefers dark. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --paper: #1B1815; + --surface: #242019; + --ink: #EEE8DC; + --muted: #A89E8C; + --hairline: #3A342A; + --hairline-strong: #4A4234; + --clay: #E2905F; + --clay-deep: #F0AC80; + --moss: #8CA378; + --accent-soft: rgba(224, 144, 95, 0.16); + --muted-soft: #332D22; + --thumb-1: #2C2720; + --thumb-2: #221E18; + --danger: #E2836C; + --danger-soft: rgba(226, 131, 108, 0.35); + --toast-accent: #A8623A; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 6px 20px rgba(0, 0, 0, 0.4); + color-scheme: dark; + } +} + +:root[data-theme="dark"] { + --paper: #1B1815; + --surface: #242019; + --ink: #EEE8DC; + --muted: #A89E8C; + --hairline: #3A342A; + --hairline-strong: #4A4234; + --clay: #E2905F; + --clay-deep: #F0AC80; + --moss: #8CA378; + --accent-soft: rgba(224, 144, 95, 0.16); + --muted-soft: #332D22; + --thumb-1: #2C2720; + --thumb-2: #221E18; + --danger: #E2836C; + --danger-soft: rgba(226, 131, 108, 0.35); + --toast-accent: #A8623A; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 6px 20px rgba(0, 0, 0, 0.4); + color-scheme: dark; } * { box-sizing: border-box; } @@ -111,7 +167,7 @@ select#sortSelect { .toggle-btn[aria-pressed="true"] { color: var(--clay-deep); border-color: var(--clay); - background: #FBEFE6; + background: var(--accent-soft); } .toggle-btn:focus-visible, .ghost-btn:focus-visible, select:focus-visible { outline: 2px solid var(--clay); @@ -121,7 +177,86 @@ select#sortSelect { .ghost-btn.active { color: var(--clay-deep); border-color: var(--clay); - background: #FBEFE6; + background: var(--accent-soft); +} + +/* --- Settings panel --- */ +.settings-wrap { + position: relative; + display: inline-block; +} +.settings-panel { + display: block; + position: absolute; + top: calc(100% + 6px); + right: 0; + left: auto; + z-index: 20; + width: 220px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: 10px; + box-shadow: var(--shadow); + padding: 14px; +} +.settings-panel[hidden] { + display: none; +} +.settings-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--ink); +} +.settings-row + .settings-row { + margin-top: 10px; +} +.settings-period input[type="number"] { + width: 64px; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--hairline); + background: var(--paper); + font: inherit; + color: var(--ink); +} +.settings-period select { + flex: 1; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--hairline); + background: var(--paper); + font: inherit; + color: var(--ink); +} +.settings-theme { + justify-content: space-between; +} +.settings-theme select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--hairline); + background: var(--paper); + font: inherit; + color: var(--ink); +} +.settings-divider { + border: none; + border-top: 1px solid var(--hairline); + margin: 12px 0; +} +.settings-hint { + margin: 10px 0 0; + font-size: 11.5px; + color: var(--muted); + line-height: 1.4; +} +.settings-error { + margin: 6px 0 0; + font-size: 11.5px; + color: var(--danger); + line-height: 1.4; } /* --- Shelf: grid view (library index cards) --- */ @@ -146,9 +281,25 @@ select#sortSelect { box-shadow: 0 2px 4px rgba(33,30,25,0.08), 0 10px 22px rgba(33,30,25,0.08); } +.card-due { + border-color: var(--clay); + box-shadow: 0 0 0 2px rgba(196, 122, 74, 0.25), var(--shadow); +} + +.due-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--clay-deep); + font-family: ui-monospace, monospace; + text-transform: uppercase; + letter-spacing: 0.04em; +} + .card-thumb { height: 120px; - background: linear-gradient(135deg, #E4DFD0, #D8D2BF); + background: linear-gradient(135deg, var(--thumb-1), var(--thumb-2)); display: flex; align-items: center; justify-content: center; @@ -215,8 +366,8 @@ select#sortSelect { cursor: pointer; font-family: inherit; } -.icon-btn:hover { color: var(--ink); border-color: #C9C2AE; } -.icon-btn.danger:hover { color: #A8412D; border-color: #E3B7A9; } +.icon-btn:hover { color: var(--ink); border-color: var(--hairline-strong); } +.icon-btn.danger:hover { color: var(--danger); border-color: var(--danger-soft); } /* --- Shelf: list view (bookshelf spines) --- */ .shelf[data-view="list"] { @@ -239,6 +390,9 @@ select#sortSelect { flex-shrink: 0; } .shelf[data-view="list"] .card-perf { + display: flex; + flex: 1; + min-width: 0; border-top: none; border-left: 1px dashed var(--hairline); } @@ -264,13 +418,42 @@ select#sortSelect { } .shelf[data-view="list"] .card-actions { margin-top: 0; + margin-left: auto; + flex-shrink: 0; +} + +/* --- Spotlight: "Worth a second look" strip --- */ +.spotlight { + margin-bottom: 28px; +} +.spotlight-heading { + font-family: Georgia, serif; + font-size: 13px; + font-weight: 600; + color: var(--muted); + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.spotlight-strip { + display: flex; + gap: 14px; + overflow-x: auto; + padding-bottom: 4px; +} +.spotlight-strip .card { + flex: 0 0 200px; + width: 200px; +} +.spotlight-strip .card-thumb { + height: 90px; } .archived-badge { font-size: 10px; padding: 2px 6px; border-radius: 999px; - background: #E6E2D3; + background: var(--muted-soft); color: var(--muted); font-family: ui-monospace, monospace; text-transform: uppercase; @@ -304,6 +487,15 @@ select#sortSelect { font-size: 13px; box-shadow: var(--shadow); } +.toast-action { + margin-left: 8px; + background: none; + border: none; + color: var(--toast-accent); + text-decoration: underline; + cursor: pointer; + font: inherit; +} @media (prefers-reduced-motion: reduce) { .card { transition: none; } @@ -312,4 +504,5 @@ select#sortSelect { @media (max-width: 560px) { .page { padding: 32px 16px 60px; } .masthead h1 { font-size: 30px; } + .spotlight-strip .card { flex-basis: 160px; width: 160px; } } diff --git a/newtab.html b/newtab.html index c8145ab..1891846 100644 --- a/newtab.html +++ b/newtab.html @@ -2,7 +2,7 @@ -Shelf +Read it later @@ -10,7 +10,7 @@
-

Shelf

+

Read it later

0 saved for later

@@ -34,20 +34,81 @@ + +
+ + +
+ +
+ diff --git a/newtab.js b/newtab.js index 2f47b5b..85003b9 100644 --- a/newtab.js +++ b/newtab.js @@ -11,11 +11,48 @@ const showArchivedBtn = document.getElementById("showArchivedBtn"); const unreadCountEl = document.getElementById("unreadCount"); const archivedNoteEl = document.getElementById("archivedNote"); const toastEl = document.getElementById("toast"); +const settingsBtn = document.getElementById("settingsBtn"); +const settingsPanel = document.getElementById("settingsPanel"); +const reminderEnabledEl = document.getElementById("reminderEnabled"); +const reminderAmountEl = document.getElementById("reminderAmount"); +const reminderUnitEl = document.getElementById("reminderUnit"); +const themeSelectEl = document.getElementById("themeSelect"); +const autoDeleteEnabledEl = document.getElementById("autoDeleteEnabled"); +const autoDeleteAmountEl = document.getElementById("autoDeleteAmount"); +const autoDeleteUnitEl = document.getElementById("autoDeleteUnit"); +const autoDeleteErrorEl = document.getElementById("autoDeleteError"); +const spotlightSectionEl = document.getElementById("spotlight"); +const spotlightStripEl = document.getElementById("spotlightStrip"); +const spotlightEnabledEl = document.getElementById("spotlightEnabled"); let items = []; -let prefs = { view: "grid", sort: "newest", showArchived: false }; +let prefs = { + view: "grid", + sort: "newest", + showArchived: false, + reminderEnabled: false, + reminderAmount: 7, + reminderUnit: "days", + theme: "system", + autoDeleteEnabled: false, + autoDeleteAmount: 30, + autoDeleteUnit: "days", + spotlightEnabled: true, +}; let toastTimer = null; +const SPOTLIGHT_COUNT = 3; +const SPOTLIGHT_COOLDOWN_MS = 60 * 60 * 1000; +const SPOTLIGHT_STATE_KEY = "shelfSpotlightState"; +let spotlightState = { lastRunAt: 0, itemIds: [] }; + +function isDue(item) { + if (!prefs.reminderEnabled || item.archived) return false; + const ms = periodMs(prefs.reminderAmount, prefs.reminderUnit); + if (!ms) return false; + return Date.now() - item.dateAdded >= ms; +} + // A short, muted palette for the bookshelf "spine" color, chosen per domain // so the same site always lands on the same color. const SPINE_COLORS = ["#C47A4A", "#55684B", "#5A6E8C", "#8C5A6E", "#8C7A4A", "#4A7C8C"]; @@ -39,6 +76,8 @@ async function loadAll() { const data = await chrome.storage.local.get([STORAGE_KEY, PREFS_KEY]); items = data[STORAGE_KEY] || []; prefs = { ...prefs, ...(data[PREFS_KEY] || {}) }; + items = await reconcileWithSync(items); + await saveItems(); } async function savePrefs() { @@ -49,13 +88,24 @@ async function saveItems() { await chrome.storage.local.set({ [STORAGE_KEY]: items }); } +// Kept out of shelfPrefs deliberately — this is derived runtime state (which +// items were last shown, and when), not a user-configurable setting. +async function loadSpotlightState() { + const data = await chrome.storage.local.get(SPOTLIGHT_STATE_KEY); + return data[SPOTLIGHT_STATE_KEY] || { lastRunAt: 0, itemIds: [] }; +} + +async function saveSpotlightState(state) { + await chrome.storage.local.set({ [SPOTLIGHT_STATE_KEY]: state }); +} + function showToast(msg, actionLabel, actionFn) { clearTimeout(toastTimer); toastEl.textContent = msg + " "; if (actionLabel && actionFn) { const btn = document.createElement("button"); btn.textContent = actionLabel; - btn.style.cssText = "margin-left:8px;background:none;border:none;color:#C47A4A;text-decoration:underline;cursor:pointer;font:inherit;"; + btn.className = "toast-action"; btn.onclick = () => { actionFn(); toastEl.hidden = true; @@ -67,6 +117,9 @@ function showToast(msg, actionLabel, actionFn) { } function applyViewButtons() { + document.documentElement.dataset.theme = prefs.theme; + themeSelectEl.value = prefs.theme; + const isGrid = prefs.view === "grid"; shelfEl.dataset.view = prefs.view; viewGridBtn.setAttribute("aria-pressed", String(isGrid)); @@ -74,6 +127,27 @@ function applyViewButtons() { sortSelectEl.value = prefs.sort; showArchivedBtn.textContent = prefs.showArchived ? "Hide archived" : "Show archived"; showArchivedBtn.classList.toggle("active", prefs.showArchived); + + reminderEnabledEl.checked = prefs.reminderEnabled; + reminderAmountEl.value = prefs.reminderAmount; + reminderUnitEl.value = prefs.reminderUnit; + reminderAmountEl.disabled = !prefs.reminderEnabled; + reminderUnitEl.disabled = !prefs.reminderEnabled; + + autoDeleteEnabledEl.checked = prefs.autoDeleteEnabled; + autoDeleteAmountEl.value = prefs.autoDeleteAmount; + autoDeleteUnitEl.value = prefs.autoDeleteUnit; + autoDeleteAmountEl.disabled = !prefs.autoDeleteEnabled; + autoDeleteUnitEl.disabled = !prefs.autoDeleteEnabled; + const autoDeleteValid = periodMs(prefs.autoDeleteAmount, prefs.autoDeleteUnit) > periodMs(prefs.reminderAmount, prefs.reminderUnit); + autoDeleteErrorEl.hidden = autoDeleteValid; + + spotlightEnabledEl.checked = prefs.spotlightEnabled; + + settingsBtn.classList.toggle( + "active", + prefs.reminderEnabled || (prefs.autoDeleteEnabled && autoDeleteValid) || prefs.spotlightEnabled + ); } function faviconOrInitial(item) { @@ -95,12 +169,13 @@ function initialBadge(item) { return span; } -function buildCard(item) { +function buildCard(item, view = prefs.view) { const card = document.createElement("div"); card.className = "card"; + if (isDue(item)) card.classList.add("card-due"); card.dataset.id = item.id; - if (prefs.view === "list") { + if (view === "list") { const spine = document.createElement("div"); spine.className = "spine"; spine.style.background = spineColorFor(item.domain); @@ -109,7 +184,7 @@ function buildCard(item) { const thumb = document.createElement("div"); thumb.className = "card-thumb"; - if (prefs.view === "grid" && !item.image) { + if (view === "grid" && !item.image) { thumb.style.background = `linear-gradient(135deg, ${spineColorFor(item.domain)}33, ${spineColorFor(item.domain)}11)`; } if (item.image) { @@ -144,6 +219,12 @@ function buildCard(item) { const dateSpan = document.createElement("span"); dateSpan.textContent = timeAgo(item.dateAdded); meta.appendChild(dateSpan); + if (isDue(item)) { + const badge = document.createElement("span"); + badge.className = "due-badge"; + badge.textContent = "due"; + meta.appendChild(badge); + } if (item.archived) { const badge = document.createElement("span"); badge.className = "archived-badge"; @@ -152,7 +233,7 @@ function buildCard(item) { } body.appendChild(meta); - if (prefs.view === "grid") { + if (view === "grid") { const actions = document.createElement("div"); actions.className = "card-actions"; actions.appendChild(makeActionBtn(item)); @@ -163,7 +244,7 @@ function buildCard(item) { perf.appendChild(body); card.appendChild(perf); - if (prefs.view === "list") { + if (view === "list") { const actions = document.createElement("div"); actions.className = "card-actions"; actions.appendChild(makeActionBtn(item)); @@ -205,6 +286,8 @@ async function toggleArchive(id) { const it = items.find((i) => i.id === id); if (!it) return; it.archived = !it.archived; + it.updatedAt = Date.now(); + items = await reconcileWithSync(items); await saveItems(); render(); } @@ -213,15 +296,90 @@ async function deleteItem(id) { const idx = items.findIndex((i) => i.id === id); if (idx === -1) return; const [removed] = items.splice(idx, 1); + items = await reconcileWithSync(items); await saveItems(); render(); showToast("Removed from shelf.", "Undo", async () => { + removed.updatedAt = Date.now(); items.splice(idx, 0, removed); + items = await reconcileWithSync(items); await saveItems(); render(); }); } +// Also run by background.js on an hourly alarm so items get cleaned up even +// when this page isn't open; this copy runs whenever the shelf is open (on +// load, and periodically after) and — unlike the background sweep — surfaces +// a courtesy toast with an Undo, since a human is actually here to see it. +async function runAutoDeleteSweep() { + if (!prefs.autoDeleteEnabled) return; + const removed = items.filter((item) => isOverdueForDeletion(item, prefs)); + if (!removed.length) return; + + items = items.filter((item) => !isOverdueForDeletion(item, prefs)); + items = await reconcileWithSync(items); + await saveItems(); + render(); + + const count = removed.length; + showToast(`Auto-deleted ${count} item${count > 1 ? "s" : ""} you never got back to.`, "Undo", async () => { + const now = Date.now(); + removed.forEach((item) => { item.updatedAt = now; }); + items.push(...removed); + items = await reconcileWithSync(items); + await saveItems(); + render(); + }); +} + +// Picks a fresh "Worth a second look" selection at most once per cooldown +// window, so opening several new tabs in quick succession (normal browsing) +// doesn't get miscounted as repeated views and falsely inflate backoff. +async function refreshSpotlightSelection() { + if (!prefs.spotlightEnabled) return; + + spotlightState = await loadSpotlightState(); + const now = Date.now(); + if (spotlightState.itemIds.length && now - (spotlightState.lastRunAt || 0) < SPOTLIGHT_COOLDOWN_MS) { + return; + } + + const picked = pickSpotlightItems(items, prefs, SPOTLIGHT_COUNT); + picked.forEach((item) => { + item.surfaceCount = (item.surfaceCount || 0) + 1; + item.lastSurfacedAt = now; + item.updatedAt = now; + }); + + if (picked.length) { + items = await reconcileWithSync(items); + await saveItems(); + } + + spotlightState = { lastRunAt: now, itemIds: picked.map((i) => i.id) }; + await saveSpotlightState(spotlightState); +} + +// Pure projection of the current selection against live item state — no +// selection logic here, so archiving/deleting a spotlighted card just makes +// it disappear (via the normal render() path) with no replacement sliding +// in until the next cooldown-triggered refresh. +function renderSpotlight() { + if (!prefs.spotlightEnabled || searchEl.value.trim()) { + spotlightSectionEl.hidden = true; + return; + } + + const visible = spotlightState.itemIds + .map((id) => items.find((i) => i.id === id)) + .filter((item) => item && !item.archived); + + spotlightStripEl.innerHTML = ""; + visible.forEach((item) => spotlightStripEl.appendChild(buildCard(item, "grid"))); + spotlightSectionEl.hidden = visible.length === 0; +} + function getFilteredSorted() { const query = searchEl.value.trim().toLowerCase(); let list = items.filter((i) => (prefs.showArchived ? true : !i.archived)); @@ -242,6 +400,13 @@ function getFilteredSorted() { domain: (a, b) => (a.domain || "").localeCompare(b.domain || ""), }; list.sort(sorters[prefs.sort] || sorters.newest); + + if (prefs.reminderEnabled) { + const due = list.filter(isDue); + const rest = list.filter((i) => !isDue(i)); + list = [...due, ...rest]; + } + return list; } @@ -259,6 +424,8 @@ function render() { emptyStateEl.hidden = items.length > 0; shelfEl.hidden = items.length === 0; + + renderSpotlight(); } // --- Event wiring --- @@ -287,6 +454,82 @@ showArchivedBtn.addEventListener("click", async () => { render(); }); +function closeSettingsPanel() { + settingsPanel.hidden = true; + settingsBtn.setAttribute("aria-expanded", "false"); +} + +settingsBtn.addEventListener("click", (e) => { + e.stopPropagation(); + const isOpen = !settingsPanel.hidden; + if (isOpen) { + closeSettingsPanel(); + } else { + settingsPanel.hidden = false; + settingsBtn.setAttribute("aria-expanded", "true"); + } +}); + +document.addEventListener("click", (e) => { + if (!settingsPanel.hidden && !e.target.closest(".settings-wrap")) closeSettingsPanel(); +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !settingsPanel.hidden) closeSettingsPanel(); +}); + +reminderEnabledEl.addEventListener("change", async () => { + prefs.reminderEnabled = reminderEnabledEl.checked; + await savePrefs(); + render(); +}); + +reminderAmountEl.addEventListener("change", async () => { + const val = Math.max(1, parseInt(reminderAmountEl.value, 10) || 1); + reminderAmountEl.value = val; + prefs.reminderAmount = val; + await savePrefs(); + render(); +}); + +reminderUnitEl.addEventListener("change", async () => { + prefs.reminderUnit = reminderUnitEl.value; + await savePrefs(); + render(); +}); + +themeSelectEl.addEventListener("change", async () => { + prefs.theme = themeSelectEl.value; + await savePrefs(); + render(); +}); + +autoDeleteEnabledEl.addEventListener("change", async () => { + prefs.autoDeleteEnabled = autoDeleteEnabledEl.checked; + await savePrefs(); + render(); +}); + +autoDeleteAmountEl.addEventListener("change", async () => { + const val = Math.max(1, parseInt(autoDeleteAmountEl.value, 10) || 1); + autoDeleteAmountEl.value = val; + prefs.autoDeleteAmount = val; + await savePrefs(); + render(); +}); + +autoDeleteUnitEl.addEventListener("change", async () => { + prefs.autoDeleteUnit = autoDeleteUnitEl.value; + await savePrefs(); + render(); +}); + +spotlightEnabledEl.addEventListener("change", async () => { + prefs.spotlightEnabled = spotlightEnabledEl.checked; + await savePrefs(); + renderSpotlight(); +}); + // Live-update if items change from another tab / the background worker. chrome.storage.onChanged.addListener((changes, area) => { if (area !== "local") return; @@ -296,7 +539,25 @@ chrome.storage.onChanged.addListener((changes, area) => { } }); +// Live-update when another device pushes changes via chrome.storage.sync. +let reconcilingSync = false; +chrome.storage.onChanged.addListener(async (changes, area) => { + if (area !== "sync" || reconcilingSync) return; + reconcilingSync = true; + try { + items = await reconcileWithSync(items); + await saveItems(); + render(); + } finally { + reconcilingSync = false; + } +}); + (async function init() { await loadAll(); render(); + await runAutoDeleteSweep(); + await refreshSpotlightSelection(); + renderSpotlight(); + setInterval(runAutoDeleteSweep, 5 * 60 * 1000); })(); diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..ec2f701 --- /dev/null +++ b/sync.js @@ -0,0 +1,205 @@ +// Shared local <-> chrome.storage.sync reconciliation for shelf items. +// Loaded by both background.js (via importScripts, Chrome service worker) +// and newtab.html (via