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
This commit is contained in:
@@ -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
|
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
|
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
|
(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.
|
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)
|
## Install — Chrome / Edge (unpacked)
|
||||||
|
|
||||||
1. Go to `chrome://extensions` (or `edge://extensions`).
|
1. Go to `chrome://extensions` (or `edge://extensions`).
|
||||||
2. Turn on **Developer mode** (top right).
|
2. Turn on **Developer mode** (top right).
|
||||||
3. Click **Load unpacked** and select this folder.
|
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)
|
## 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/)
|
> [Developer Edition / Nightly build](https://www.mozilla.org/firefox/developer/)
|
||||||
> with `xpinstall.signatures.required` set to `false`.
|
> 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
|
## 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
|
- 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`.
|
any time; sizes 16/32/48/128 are already wired up in `manifest.json`.
|
||||||
|
|||||||
+50
-4
@@ -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
|
// Works under both the `chrome` namespace (Chrome/Edge) and Firefox's
|
||||||
// chrome.* alias for WebExtensions.
|
// 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 STORAGE_KEY = "shelfItems";
|
||||||
|
const PREFS_KEY = "shelfPrefs";
|
||||||
|
const AUTO_DELETE_ALARM = "autoDeleteSweep";
|
||||||
|
|
||||||
function domainOf(url) {
|
function domainOf(url) {
|
||||||
try {
|
try {
|
||||||
@@ -21,25 +30,51 @@ async function setItems(items) {
|
|||||||
await chrome.storage.local.set({ [STORAGE_KEY]: 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 }) {
|
async function addItem({ url, title, favIconUrl, image }) {
|
||||||
if (!url) return;
|
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.
|
// De-dupe: if already saved, just bump it to the top and refresh metadata.
|
||||||
const existingIndex = items.findIndex((i) => i.url === url);
|
const existingIndex = items.findIndex((i) => i.url === url);
|
||||||
|
const now = Date.now();
|
||||||
const entry = {
|
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,
|
url,
|
||||||
title: title || url,
|
title: title || url,
|
||||||
domain: domainOf(url),
|
domain: domainOf(url),
|
||||||
favicon: favIconUrl || "",
|
favicon: favIconUrl || "",
|
||||||
image: image || (existingIndex >= 0 ? items[existingIndex].image : ""),
|
image: image || (existingIndex >= 0 ? items[existingIndex].image : ""),
|
||||||
dateAdded: Date.now(),
|
dateAdded: now,
|
||||||
archived: false,
|
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);
|
if (existingIndex >= 0) items.splice(existingIndex, 1);
|
||||||
items.unshift(entry);
|
items.unshift(entry);
|
||||||
|
items = typeof reconcileWithSync === "function" ? await reconcileWithSync(items) : items;
|
||||||
await setItems(items);
|
await setItems(items);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
@@ -111,3 +146,14 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|||||||
if (tab && tab.id) flashBadge(tab.id, "✓");
|
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();
|
||||||
|
});
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Shelf — Read Later",
|
"name": "Read it later",
|
||||||
"version": "1.0.0",
|
"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.",
|
"description": "Save pages and links for later, and see them all as a sortable shelf every time you open a new tab.",
|
||||||
"icons": {
|
"icons": {
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
},
|
},
|
||||||
"permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage"],
|
"permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage", "alarms"],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js",
|
"service_worker": "background.js",
|
||||||
"scripts": ["background.js"]
|
"scripts": ["sync.js", "background.js"]
|
||||||
},
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_title": "Save this page to Shelf",
|
"default_title": "Save this page to Read it later",
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"32": "icons/icon32.png",
|
"32": "icons/icon32.png",
|
||||||
|
|||||||
+199
-6
@@ -4,10 +4,66 @@
|
|||||||
--ink: #211E19;
|
--ink: #211E19;
|
||||||
--muted: #756E60;
|
--muted: #756E60;
|
||||||
--hairline: #DBD6C7;
|
--hairline: #DBD6C7;
|
||||||
|
--hairline-strong: #C9C2AE;
|
||||||
--clay: #C47A4A;
|
--clay: #C47A4A;
|
||||||
--clay-deep: #A8623A;
|
--clay-deep: #A8623A;
|
||||||
--moss: #55684B;
|
--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);
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
@@ -111,7 +167,7 @@ select#sortSelect {
|
|||||||
.toggle-btn[aria-pressed="true"] {
|
.toggle-btn[aria-pressed="true"] {
|
||||||
color: var(--clay-deep);
|
color: var(--clay-deep);
|
||||||
border-color: var(--clay);
|
border-color: var(--clay);
|
||||||
background: #FBEFE6;
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.toggle-btn:focus-visible, .ghost-btn:focus-visible, select:focus-visible {
|
.toggle-btn:focus-visible, .ghost-btn:focus-visible, select:focus-visible {
|
||||||
outline: 2px solid var(--clay);
|
outline: 2px solid var(--clay);
|
||||||
@@ -121,7 +177,86 @@ select#sortSelect {
|
|||||||
.ghost-btn.active {
|
.ghost-btn.active {
|
||||||
color: var(--clay-deep);
|
color: var(--clay-deep);
|
||||||
border-color: var(--clay);
|
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) --- */
|
/* --- 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);
|
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 {
|
.card-thumb {
|
||||||
height: 120px;
|
height: 120px;
|
||||||
background: linear-gradient(135deg, #E4DFD0, #D8D2BF);
|
background: linear-gradient(135deg, var(--thumb-1), var(--thumb-2));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -215,8 +366,8 @@ select#sortSelect {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.icon-btn:hover { color: var(--ink); border-color: #C9C2AE; }
|
.icon-btn:hover { color: var(--ink); border-color: var(--hairline-strong); }
|
||||||
.icon-btn.danger:hover { color: #A8412D; border-color: #E3B7A9; }
|
.icon-btn.danger:hover { color: var(--danger); border-color: var(--danger-soft); }
|
||||||
|
|
||||||
/* --- Shelf: list view (bookshelf spines) --- */
|
/* --- Shelf: list view (bookshelf spines) --- */
|
||||||
.shelf[data-view="list"] {
|
.shelf[data-view="list"] {
|
||||||
@@ -239,6 +390,9 @@ select#sortSelect {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.shelf[data-view="list"] .card-perf {
|
.shelf[data-view="list"] .card-perf {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-left: 1px dashed var(--hairline);
|
border-left: 1px dashed var(--hairline);
|
||||||
}
|
}
|
||||||
@@ -264,13 +418,42 @@ select#sortSelect {
|
|||||||
}
|
}
|
||||||
.shelf[data-view="list"] .card-actions {
|
.shelf[data-view="list"] .card-actions {
|
||||||
margin-top: 0;
|
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 {
|
.archived-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: #E6E2D3;
|
background: var(--muted-soft);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -304,6 +487,15 @@ select#sortSelect {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
box-shadow: var(--shadow);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card { transition: none; }
|
.card { transition: none; }
|
||||||
@@ -312,4 +504,5 @@ select#sortSelect {
|
|||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.page { padding: 32px 16px 60px; }
|
.page { padding: 32px 16px 60px; }
|
||||||
.masthead h1 { font-size: 30px; }
|
.masthead h1 { font-size: 30px; }
|
||||||
|
.spotlight-strip .card { flex-basis: 160px; width: 160px; }
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-3
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Shelf</title>
|
<title>Read it later</title>
|
||||||
<link rel="stylesheet" href="newtab.css" />
|
<link rel="stylesheet" href="newtab.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<header class="masthead">
|
<header class="masthead">
|
||||||
<div class="masthead-title">
|
<div class="masthead-title">
|
||||||
<span class="mark" aria-hidden="true">🔖</span>
|
<span class="mark" aria-hidden="true">🔖</span>
|
||||||
<h1>Shelf</h1>
|
<h1>Read it later</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="tally"><span id="unreadCount">0</span> saved for later<span id="archivedNote"></span></p>
|
<p class="tally"><span id="unreadCount">0</span> saved for later<span id="archivedNote"></span></p>
|
||||||
</header>
|
</header>
|
||||||
@@ -34,20 +34,81 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="showArchivedBtn" class="ghost-btn">Show archived</button>
|
<button id="showArchivedBtn" class="ghost-btn">Show archived</button>
|
||||||
|
|
||||||
|
<div class="settings-wrap">
|
||||||
|
<button id="settingsBtn" class="toggle-btn" title="Settings" aria-haspopup="true" aria-expanded="false">⚙</button>
|
||||||
|
<div id="settingsPanel" class="settings-panel" hidden>
|
||||||
|
<div class="settings-row settings-theme">
|
||||||
|
<label for="themeSelect">Theme</label>
|
||||||
|
<select id="themeSelect">
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="settings-divider" />
|
||||||
|
|
||||||
|
<label class="settings-row">
|
||||||
|
<input type="checkbox" id="reminderEnabled" />
|
||||||
|
Remind me after
|
||||||
|
</label>
|
||||||
|
<div class="settings-row settings-period">
|
||||||
|
<input type="number" id="reminderAmount" min="1" max="999" value="7" />
|
||||||
|
<select id="reminderUnit">
|
||||||
|
<option value="days">days</option>
|
||||||
|
<option value="weeks">weeks</option>
|
||||||
|
<option value="months">months</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">Overdue items are pinned to the top and highlighted.</p>
|
||||||
|
|
||||||
|
<hr class="settings-divider" />
|
||||||
|
|
||||||
|
<label class="settings-row">
|
||||||
|
<input type="checkbox" id="autoDeleteEnabled" />
|
||||||
|
Delete unarchived items after
|
||||||
|
</label>
|
||||||
|
<div class="settings-row settings-period">
|
||||||
|
<input type="number" id="autoDeleteAmount" min="1" max="999" value="30" />
|
||||||
|
<select id="autoDeleteUnit">
|
||||||
|
<option value="days">days</option>
|
||||||
|
<option value="weeks">weeks</option>
|
||||||
|
<option value="months">months</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p class="settings-hint">Archive anything you want to keep — this only removes items you never got back to.</p>
|
||||||
|
<p class="settings-error" id="autoDeleteError" hidden>Must be longer than the reminder period above, so you're always warned first.</p>
|
||||||
|
|
||||||
|
<hr class="settings-divider" />
|
||||||
|
|
||||||
|
<label class="settings-row">
|
||||||
|
<input type="checkbox" id="spotlightEnabled" />
|
||||||
|
Worth a second look
|
||||||
|
</label>
|
||||||
|
<p class="settings-hint">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.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="spotlight" class="spotlight" hidden aria-label="Worth a second look">
|
||||||
|
<h2 class="spotlight-heading">Worth a second look</h2>
|
||||||
|
<div id="spotlightStrip" class="spotlight-strip"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<main id="shelf" class="shelf" data-view="grid">
|
<main id="shelf" class="shelf" data-view="grid">
|
||||||
<!-- items injected here -->
|
<!-- items injected here -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="emptyState" class="empty-state" hidden>
|
<div id="emptyState" class="empty-state" hidden>
|
||||||
<p class="empty-title">Your shelf is empty.</p>
|
<p class="empty-title">Your shelf is empty.</p>
|
||||||
<p class="empty-sub">Click the Shelf icon in your toolbar, or right-click any page or link, to save something for later.</p>
|
<p class="empty-sub">Click the Read it later icon in your toolbar, or right-click any page or link, to save something for later.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast" hidden></div>
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
|
||||||
|
<script src="sync.js"></script>
|
||||||
<script src="newtab.js"></script>
|
<script src="newtab.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,11 +11,48 @@ const showArchivedBtn = document.getElementById("showArchivedBtn");
|
|||||||
const unreadCountEl = document.getElementById("unreadCount");
|
const unreadCountEl = document.getElementById("unreadCount");
|
||||||
const archivedNoteEl = document.getElementById("archivedNote");
|
const archivedNoteEl = document.getElementById("archivedNote");
|
||||||
const toastEl = document.getElementById("toast");
|
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 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;
|
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
|
// A short, muted palette for the bookshelf "spine" color, chosen per domain
|
||||||
// so the same site always lands on the same color.
|
// so the same site always lands on the same color.
|
||||||
const SPINE_COLORS = ["#C47A4A", "#55684B", "#5A6E8C", "#8C5A6E", "#8C7A4A", "#4A7C8C"];
|
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]);
|
const data = await chrome.storage.local.get([STORAGE_KEY, PREFS_KEY]);
|
||||||
items = data[STORAGE_KEY] || [];
|
items = data[STORAGE_KEY] || [];
|
||||||
prefs = { ...prefs, ...(data[PREFS_KEY] || {}) };
|
prefs = { ...prefs, ...(data[PREFS_KEY] || {}) };
|
||||||
|
items = await reconcileWithSync(items);
|
||||||
|
await saveItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePrefs() {
|
async function savePrefs() {
|
||||||
@@ -49,13 +88,24 @@ async function saveItems() {
|
|||||||
await chrome.storage.local.set({ [STORAGE_KEY]: items });
|
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) {
|
function showToast(msg, actionLabel, actionFn) {
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastEl.textContent = msg + " ";
|
toastEl.textContent = msg + " ";
|
||||||
if (actionLabel && actionFn) {
|
if (actionLabel && actionFn) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.textContent = actionLabel;
|
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 = () => {
|
btn.onclick = () => {
|
||||||
actionFn();
|
actionFn();
|
||||||
toastEl.hidden = true;
|
toastEl.hidden = true;
|
||||||
@@ -67,6 +117,9 @@ function showToast(msg, actionLabel, actionFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyViewButtons() {
|
function applyViewButtons() {
|
||||||
|
document.documentElement.dataset.theme = prefs.theme;
|
||||||
|
themeSelectEl.value = prefs.theme;
|
||||||
|
|
||||||
const isGrid = prefs.view === "grid";
|
const isGrid = prefs.view === "grid";
|
||||||
shelfEl.dataset.view = prefs.view;
|
shelfEl.dataset.view = prefs.view;
|
||||||
viewGridBtn.setAttribute("aria-pressed", String(isGrid));
|
viewGridBtn.setAttribute("aria-pressed", String(isGrid));
|
||||||
@@ -74,6 +127,27 @@ function applyViewButtons() {
|
|||||||
sortSelectEl.value = prefs.sort;
|
sortSelectEl.value = prefs.sort;
|
||||||
showArchivedBtn.textContent = prefs.showArchived ? "Hide archived" : "Show archived";
|
showArchivedBtn.textContent = prefs.showArchived ? "Hide archived" : "Show archived";
|
||||||
showArchivedBtn.classList.toggle("active", prefs.showArchived);
|
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) {
|
function faviconOrInitial(item) {
|
||||||
@@ -95,12 +169,13 @@ function initialBadge(item) {
|
|||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCard(item) {
|
function buildCard(item, view = prefs.view) {
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "card";
|
card.className = "card";
|
||||||
|
if (isDue(item)) card.classList.add("card-due");
|
||||||
card.dataset.id = item.id;
|
card.dataset.id = item.id;
|
||||||
|
|
||||||
if (prefs.view === "list") {
|
if (view === "list") {
|
||||||
const spine = document.createElement("div");
|
const spine = document.createElement("div");
|
||||||
spine.className = "spine";
|
spine.className = "spine";
|
||||||
spine.style.background = spineColorFor(item.domain);
|
spine.style.background = spineColorFor(item.domain);
|
||||||
@@ -109,7 +184,7 @@ function buildCard(item) {
|
|||||||
|
|
||||||
const thumb = document.createElement("div");
|
const thumb = document.createElement("div");
|
||||||
thumb.className = "card-thumb";
|
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)`;
|
thumb.style.background = `linear-gradient(135deg, ${spineColorFor(item.domain)}33, ${spineColorFor(item.domain)}11)`;
|
||||||
}
|
}
|
||||||
if (item.image) {
|
if (item.image) {
|
||||||
@@ -144,6 +219,12 @@ function buildCard(item) {
|
|||||||
const dateSpan = document.createElement("span");
|
const dateSpan = document.createElement("span");
|
||||||
dateSpan.textContent = timeAgo(item.dateAdded);
|
dateSpan.textContent = timeAgo(item.dateAdded);
|
||||||
meta.appendChild(dateSpan);
|
meta.appendChild(dateSpan);
|
||||||
|
if (isDue(item)) {
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "due-badge";
|
||||||
|
badge.textContent = "due";
|
||||||
|
meta.appendChild(badge);
|
||||||
|
}
|
||||||
if (item.archived) {
|
if (item.archived) {
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.className = "archived-badge";
|
badge.className = "archived-badge";
|
||||||
@@ -152,7 +233,7 @@ function buildCard(item) {
|
|||||||
}
|
}
|
||||||
body.appendChild(meta);
|
body.appendChild(meta);
|
||||||
|
|
||||||
if (prefs.view === "grid") {
|
if (view === "grid") {
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "card-actions";
|
actions.className = "card-actions";
|
||||||
actions.appendChild(makeActionBtn(item));
|
actions.appendChild(makeActionBtn(item));
|
||||||
@@ -163,7 +244,7 @@ function buildCard(item) {
|
|||||||
perf.appendChild(body);
|
perf.appendChild(body);
|
||||||
card.appendChild(perf);
|
card.appendChild(perf);
|
||||||
|
|
||||||
if (prefs.view === "list") {
|
if (view === "list") {
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "card-actions";
|
actions.className = "card-actions";
|
||||||
actions.appendChild(makeActionBtn(item));
|
actions.appendChild(makeActionBtn(item));
|
||||||
@@ -205,6 +286,8 @@ async function toggleArchive(id) {
|
|||||||
const it = items.find((i) => i.id === id);
|
const it = items.find((i) => i.id === id);
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
it.archived = !it.archived;
|
it.archived = !it.archived;
|
||||||
|
it.updatedAt = Date.now();
|
||||||
|
items = await reconcileWithSync(items);
|
||||||
await saveItems();
|
await saveItems();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -213,15 +296,90 @@ async function deleteItem(id) {
|
|||||||
const idx = items.findIndex((i) => i.id === id);
|
const idx = items.findIndex((i) => i.id === id);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
const [removed] = items.splice(idx, 1);
|
const [removed] = items.splice(idx, 1);
|
||||||
|
items = await reconcileWithSync(items);
|
||||||
await saveItems();
|
await saveItems();
|
||||||
render();
|
render();
|
||||||
showToast("Removed from shelf.", "Undo", async () => {
|
showToast("Removed from shelf.", "Undo", async () => {
|
||||||
|
removed.updatedAt = Date.now();
|
||||||
items.splice(idx, 0, removed);
|
items.splice(idx, 0, removed);
|
||||||
|
items = await reconcileWithSync(items);
|
||||||
await saveItems();
|
await saveItems();
|
||||||
render();
|
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() {
|
function getFilteredSorted() {
|
||||||
const query = searchEl.value.trim().toLowerCase();
|
const query = searchEl.value.trim().toLowerCase();
|
||||||
let list = items.filter((i) => (prefs.showArchived ? true : !i.archived));
|
let list = items.filter((i) => (prefs.showArchived ? true : !i.archived));
|
||||||
@@ -242,6 +400,13 @@ function getFilteredSorted() {
|
|||||||
domain: (a, b) => (a.domain || "").localeCompare(b.domain || ""),
|
domain: (a, b) => (a.domain || "").localeCompare(b.domain || ""),
|
||||||
};
|
};
|
||||||
list.sort(sorters[prefs.sort] || sorters.newest);
|
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;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +424,8 @@ function render() {
|
|||||||
|
|
||||||
emptyStateEl.hidden = items.length > 0;
|
emptyStateEl.hidden = items.length > 0;
|
||||||
shelfEl.hidden = items.length === 0;
|
shelfEl.hidden = items.length === 0;
|
||||||
|
|
||||||
|
renderSpotlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Event wiring ---
|
// --- Event wiring ---
|
||||||
@@ -287,6 +454,82 @@ showArchivedBtn.addEventListener("click", async () => {
|
|||||||
render();
|
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.
|
// Live-update if items change from another tab / the background worker.
|
||||||
chrome.storage.onChanged.addListener((changes, area) => {
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
if (area !== "local") return;
|
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() {
|
(async function init() {
|
||||||
await loadAll();
|
await loadAll();
|
||||||
render();
|
render();
|
||||||
|
await runAutoDeleteSweep();
|
||||||
|
await refreshSpotlightSelection();
|
||||||
|
renderSpotlight();
|
||||||
|
setInterval(runAutoDeleteSweep, 5 * 60 * 1000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 <script>, Firefox event page + the shelf UI).
|
||||||
|
//
|
||||||
|
// Sync storage only ever holds a lightweight metadata projection of each
|
||||||
|
// item (no screenshot thumbnails — chrome.storage.sync caps a single key at
|
||||||
|
// 8KB), one key per item ("m_<id>") so the per-key cap is never a problem;
|
||||||
|
// the 512-key / ~100KB total quota is the real ceiling. All sync writes are
|
||||||
|
// best-effort: if the user isn't signed in, is offline, or is over quota,
|
||||||
|
// errors are swallowed and the extension keeps working from local storage
|
||||||
|
// alone.
|
||||||
|
|
||||||
|
const SYNC_KEY_PREFIX = "m_";
|
||||||
|
const SYNCED_IDS_KEY = "shelfSyncedIds";
|
||||||
|
const FAVICON_SYNC_MAX_LEN = 1000;
|
||||||
|
|
||||||
|
function syncKeyFor(id) {
|
||||||
|
return SYNC_KEY_PREFIX + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by the reminder highlight and the auto-delete sweep, in both
|
||||||
|
// background.js and newtab.js, so the two periods can never drift apart.
|
||||||
|
// Months are approximated as 30 days — no need for calendar precision here.
|
||||||
|
const UNIT_MS = { days: 86400000, weeks: 604800000, months: 2592000000 };
|
||||||
|
|
||||||
|
function periodMs(amount, unit) {
|
||||||
|
return (amount || 0) * (UNIT_MS[unit] || UNIT_MS.days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether an item is old enough (and eligible) to be auto-deleted.
|
||||||
|
// Enforces the same "auto-delete period must outlast the reminder period"
|
||||||
|
// invariant as the settings-panel validation, as a hard safety net: even if
|
||||||
|
// prefs somehow end up misconfigured (e.g. edited directly in storage), this
|
||||||
|
// refuses to delete anything until the auto-delete period genuinely exceeds
|
||||||
|
// the reminder period — so a user is always reminded before something is
|
||||||
|
// removed, never surprised by it.
|
||||||
|
function isOverdueForDeletion(item, prefs) {
|
||||||
|
if (!prefs || !prefs.autoDeleteEnabled || item.archived) return false;
|
||||||
|
const deleteMs = periodMs(prefs.autoDeleteAmount, prefs.autoDeleteUnit);
|
||||||
|
const reminderMs = periodMs(prefs.reminderAmount, prefs.reminderUnit);
|
||||||
|
if (!deleteMs || deleteMs <= reminderMs) return false;
|
||||||
|
return Date.now() - item.dateAdded >= deleteMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaFromItem(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
domain: item.domain,
|
||||||
|
favicon: item.favicon && item.favicon.length <= FAVICON_SYNC_MAX_LEN ? item.favicon : "",
|
||||||
|
dateAdded: item.dateAdded,
|
||||||
|
archived: !!item.archived,
|
||||||
|
updatedAt: item.updatedAt || item.dateAdded,
|
||||||
|
surfaceCount: item.surfaceCount || 0,
|
||||||
|
lastSurfacedAt: item.lastSurfacedAt || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Worth a second look" spotlight: proactively resurfaces a few old,
|
||||||
|
// forgotten, unarchived items on the new tab page. The more times an item
|
||||||
|
// has been surfaced without being acted on, the longer before it's shown
|
||||||
|
// again — so it never nags forever on the same links, but never fully goes
|
||||||
|
// silent either (it always comes back eventually).
|
||||||
|
const SPOTLIGHT_BACKOFF_DAYS = [3, 7, 14, 30, 60];
|
||||||
|
// Used when the reminder feature is off (its default state) — otherwise
|
||||||
|
// "old enough to matter" would have no threshold at all and the spotlight
|
||||||
|
// would silently do nothing for most users.
|
||||||
|
const SPOTLIGHT_FALLBACK_MIN_AGE_MS = periodMs(14, "days");
|
||||||
|
|
||||||
|
function spotlightMinAgeMs(prefs) {
|
||||||
|
return prefs.reminderEnabled
|
||||||
|
? periodMs(prefs.reminderAmount, prefs.reminderUnit)
|
||||||
|
: SPOTLIGHT_FALLBACK_MIN_AGE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passesSpotlightBackoff(item) {
|
||||||
|
if (!item.lastSurfacedAt) return true;
|
||||||
|
const tier = Math.min(item.surfaceCount || 0, SPOTLIGHT_BACKOFF_DAYS.length - 1);
|
||||||
|
return Date.now() - item.lastSurfacedAt >= SPOTLIGHT_BACKOFF_DAYS[tier] * UNIT_MS.days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpotlightCandidate(item, prefs) {
|
||||||
|
if (item.archived) return false;
|
||||||
|
if (Date.now() - item.dateAdded < spotlightMinAgeMs(prefs)) return false;
|
||||||
|
return passesSpotlightBackoff(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleInPlace(arr) {
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefers items that have never been (or were least recently) surfaced,
|
||||||
|
// with a light shuffle among the leading candidates so it isn't the exact
|
||||||
|
// same set every single time.
|
||||||
|
function pickSpotlightItems(items, prefs, count) {
|
||||||
|
const candidates = items.filter((item) => isSpotlightCandidate(item, prefs));
|
||||||
|
candidates.sort((a, b) => (a.lastSurfacedAt || 0) - (b.lastSurfacedAt || 0));
|
||||||
|
const pool = shuffleInPlace(candidates.slice(0, count * 2));
|
||||||
|
return pool.slice(0, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSyncMeta() {
|
||||||
|
const all = await chrome.storage.sync.get(null);
|
||||||
|
return Object.keys(all)
|
||||||
|
.filter((k) => k.startsWith(SYNC_KEY_PREFIX))
|
||||||
|
.map((k) => all[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSyncedIds() {
|
||||||
|
const data = await chrome.storage.local.get(SYNCED_IDS_KEY);
|
||||||
|
return new Set(data[SYNCED_IDS_KEY] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeSyncedIds(idSet) {
|
||||||
|
await chrome.storage.local.set({ [SYNCED_IDS_KEY]: [...idSet] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulls remote adds/edits into `items` and pushes local adds/edits/deletes
|
||||||
|
// out to chrome.storage.sync. Returns the merged items array; does not
|
||||||
|
// persist it — callers are responsible for saving the result locally.
|
||||||
|
async function reconcileWithSync(items) {
|
||||||
|
let syncItems, previouslySynced;
|
||||||
|
try {
|
||||||
|
[syncItems, previouslySynced] = await Promise.all([readSyncMeta(), readSyncedIds()]);
|
||||||
|
} catch {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncById = new Map(syncItems.map((m) => [m.id, m]));
|
||||||
|
const localById = new Map(items.map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
// Items that were synced before but are no longer in sync storage were
|
||||||
|
// deleted on another device — drop them here too.
|
||||||
|
let merged = items.filter((item) => !(previouslySynced.has(item.id) && !syncById.has(item.id)));
|
||||||
|
|
||||||
|
// Pull in remote adds/edits.
|
||||||
|
for (const meta of syncItems) {
|
||||||
|
const local = localById.get(meta.id);
|
||||||
|
if (!local) {
|
||||||
|
// Absent locally but previously synced means *this* device deleted it;
|
||||||
|
// don't resurrect it from a remote copy that hasn't caught up yet —
|
||||||
|
// let the deletion below propagate out instead.
|
||||||
|
if (previouslySynced.has(meta.id)) continue;
|
||||||
|
merged.push({
|
||||||
|
id: meta.id,
|
||||||
|
url: meta.url,
|
||||||
|
title: meta.title,
|
||||||
|
domain: meta.domain,
|
||||||
|
favicon: meta.favicon || "",
|
||||||
|
image: "",
|
||||||
|
dateAdded: meta.dateAdded,
|
||||||
|
archived: meta.archived,
|
||||||
|
updatedAt: meta.updatedAt,
|
||||||
|
surfaceCount: meta.surfaceCount || 0,
|
||||||
|
lastSurfacedAt: meta.lastSurfacedAt || 0,
|
||||||
|
});
|
||||||
|
} else if ((meta.updatedAt || 0) > (local.updatedAt || local.dateAdded || 0)) {
|
||||||
|
local.title = meta.title;
|
||||||
|
local.domain = meta.domain;
|
||||||
|
local.archived = meta.archived;
|
||||||
|
local.dateAdded = meta.dateAdded;
|
||||||
|
local.favicon = meta.favicon || local.favicon;
|
||||||
|
local.updatedAt = meta.updatedAt;
|
||||||
|
local.surfaceCount = meta.surfaceCount || 0;
|
||||||
|
local.lastSurfacedAt = meta.lastSurfacedAt || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push local adds/edits that are newer than (or missing from) sync.
|
||||||
|
const nextSyncedIds = new Set(syncById.keys());
|
||||||
|
const writes = {};
|
||||||
|
for (const item of merged) {
|
||||||
|
const remote = syncById.get(item.id);
|
||||||
|
const localUpdatedAt = item.updatedAt || item.dateAdded;
|
||||||
|
if (!remote || localUpdatedAt > (remote.updatedAt || 0)) {
|
||||||
|
writes[syncKeyFor(item.id)] = metaFromItem(item);
|
||||||
|
nextSyncedIds.add(item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate local deletions: ids we used to sync that are gone locally now.
|
||||||
|
const mergedIds = new Set(merged.map((i) => i.id));
|
||||||
|
const removals = [];
|
||||||
|
for (const id of previouslySynced) {
|
||||||
|
if (!mergedIds.has(id) && syncById.has(id)) {
|
||||||
|
removals.push(syncKeyFor(id));
|
||||||
|
nextSyncedIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Object.keys(writes).length) await chrome.storage.sync.set(writes);
|
||||||
|
if (removals.length) await chrome.storage.sync.remove(removals);
|
||||||
|
await writeSyncedIds(nextSyncedIds);
|
||||||
|
} catch {
|
||||||
|
// Best effort — local storage remains the source of truth for this device.
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user