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:
@@ -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);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user