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 @@
-0 saved for later
Overdue items are pinned to the top and highlighted.
+ +Archive anything you want to keep — this only removes items you never got back to.
+Must be longer than the reminder period above, so you're always warned first.
+ +Resurfaces a few old, forgotten items each time you open a new tab. The more you pass on one, the less often it comes back.
+Your shelf is empty.
-Click the Shelf icon in your toolbar, or right-click any page or link, to save something for later.
+Click the Read it later icon in your toolbar, or right-click any page or link, to save something for later.