- 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
564 lines
17 KiB
JavaScript
564 lines
17 KiB
JavaScript
const STORAGE_KEY = "shelfItems";
|
|
const PREFS_KEY = "shelfPrefs";
|
|
|
|
const shelfEl = document.getElementById("shelf");
|
|
const emptyStateEl = document.getElementById("emptyState");
|
|
const searchEl = document.getElementById("search");
|
|
const sortSelectEl = document.getElementById("sortSelect");
|
|
const viewGridBtn = document.getElementById("viewGridBtn");
|
|
const viewListBtn = document.getElementById("viewListBtn");
|
|
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,
|
|
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"];
|
|
|
|
function spineColorFor(domain) {
|
|
let hash = 0;
|
|
for (let i = 0; i < domain.length; i++) hash = (hash * 31 + domain.charCodeAt(i)) >>> 0;
|
|
return SPINE_COLORS[hash % SPINE_COLORS.length];
|
|
}
|
|
|
|
function timeAgo(ts) {
|
|
const diff = Date.now() - ts;
|
|
const min = 60000, hr = 3600000, day = 86400000;
|
|
if (diff < hr) return `${Math.max(1, Math.round(diff / min))}m ago`;
|
|
if (diff < day) return `${Math.round(diff / hr)}h ago`;
|
|
if (diff < day * 30) return `${Math.round(diff / day)}d ago`;
|
|
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
}
|
|
|
|
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() {
|
|
await chrome.storage.local.set({ [PREFS_KEY]: prefs });
|
|
}
|
|
|
|
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.className = "toast-action";
|
|
btn.onclick = () => {
|
|
actionFn();
|
|
toastEl.hidden = true;
|
|
};
|
|
toastEl.appendChild(btn);
|
|
}
|
|
toastEl.hidden = false;
|
|
toastTimer = setTimeout(() => (toastEl.hidden = true), 5000);
|
|
}
|
|
|
|
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));
|
|
viewListBtn.setAttribute("aria-pressed", String(!isGrid));
|
|
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) {
|
|
if (item.favicon) {
|
|
const img = document.createElement("img");
|
|
img.src = item.favicon;
|
|
img.alt = "";
|
|
img.style.cssText = "width:20px;height:20px;object-fit:contain;";
|
|
img.onerror = () => { img.replaceWith(initialBadge(item)); };
|
|
return img;
|
|
}
|
|
return initialBadge(item);
|
|
}
|
|
|
|
function initialBadge(item) {
|
|
const span = document.createElement("span");
|
|
span.className = "favicon-fallback";
|
|
span.textContent = (item.domain || item.title || "?").charAt(0).toUpperCase();
|
|
return span;
|
|
}
|
|
|
|
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 (view === "list") {
|
|
const spine = document.createElement("div");
|
|
spine.className = "spine";
|
|
spine.style.background = spineColorFor(item.domain);
|
|
card.appendChild(spine);
|
|
}
|
|
|
|
const thumb = document.createElement("div");
|
|
thumb.className = "card-thumb";
|
|
if (view === "grid" && !item.image) {
|
|
thumb.style.background = `linear-gradient(135deg, ${spineColorFor(item.domain)}33, ${spineColorFor(item.domain)}11)`;
|
|
}
|
|
if (item.image) {
|
|
const img = document.createElement("img");
|
|
img.src = item.image;
|
|
img.alt = "";
|
|
thumb.appendChild(img);
|
|
} else {
|
|
thumb.appendChild(faviconOrInitial(item));
|
|
}
|
|
card.appendChild(thumb);
|
|
|
|
const perf = document.createElement("div");
|
|
perf.className = "card-perf";
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "card-body";
|
|
|
|
const title = document.createElement("a");
|
|
title.className = "card-title";
|
|
title.href = item.url;
|
|
title.textContent = item.title || item.url;
|
|
title.title = item.url;
|
|
title.addEventListener("click", () => markOpened(item.id));
|
|
body.appendChild(title);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "card-meta";
|
|
const domainSpan = document.createElement("span");
|
|
domainSpan.textContent = item.domain || "";
|
|
meta.appendChild(domainSpan);
|
|
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";
|
|
badge.textContent = "archived";
|
|
meta.appendChild(badge);
|
|
}
|
|
body.appendChild(meta);
|
|
|
|
if (view === "grid") {
|
|
const actions = document.createElement("div");
|
|
actions.className = "card-actions";
|
|
actions.appendChild(makeActionBtn(item));
|
|
actions.appendChild(makeDeleteBtn(item));
|
|
body.appendChild(actions);
|
|
}
|
|
|
|
perf.appendChild(body);
|
|
card.appendChild(perf);
|
|
|
|
if (view === "list") {
|
|
const actions = document.createElement("div");
|
|
actions.className = "card-actions";
|
|
actions.appendChild(makeActionBtn(item));
|
|
actions.appendChild(makeDeleteBtn(item));
|
|
card.querySelector(".card-body").appendChild(actions);
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
function makeActionBtn(item) {
|
|
const btn = document.createElement("button");
|
|
btn.className = "icon-btn";
|
|
btn.textContent = item.archived ? "Unarchive" : "Archive";
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
toggleArchive(item.id);
|
|
});
|
|
return btn;
|
|
}
|
|
|
|
function makeDeleteBtn(item) {
|
|
const btn = document.createElement("button");
|
|
btn.className = "icon-btn danger";
|
|
btn.textContent = "Delete";
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
deleteItem(item.id);
|
|
});
|
|
return btn;
|
|
}
|
|
|
|
async function markOpened(id) {
|
|
// Leave the item in place; opening just navigates. No state change needed,
|
|
// but this hook exists in case future versions want "mark as read on open."
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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));
|
|
|
|
if (query) {
|
|
list = list.filter(
|
|
(i) =>
|
|
(i.title || "").toLowerCase().includes(query) ||
|
|
(i.domain || "").toLowerCase().includes(query) ||
|
|
(i.url || "").toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
const sorters = {
|
|
newest: (a, b) => b.dateAdded - a.dateAdded,
|
|
oldest: (a, b) => a.dateAdded - b.dateAdded,
|
|
title: (a, b) => (a.title || "").localeCompare(b.title || ""),
|
|
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;
|
|
}
|
|
|
|
function render() {
|
|
applyViewButtons();
|
|
const list = getFilteredSorted();
|
|
|
|
shelfEl.innerHTML = "";
|
|
list.forEach((item) => shelfEl.appendChild(buildCard(item)));
|
|
|
|
const unreadTotal = items.filter((i) => !i.archived).length;
|
|
unreadCountEl.textContent = unreadTotal;
|
|
const archivedTotal = items.length - unreadTotal;
|
|
archivedNoteEl.textContent = archivedTotal > 0 ? ` · ${archivedTotal} archived` : "";
|
|
|
|
emptyStateEl.hidden = items.length > 0;
|
|
shelfEl.hidden = items.length === 0;
|
|
|
|
renderSpotlight();
|
|
}
|
|
|
|
// --- Event wiring ---
|
|
searchEl.addEventListener("input", render);
|
|
|
|
sortSelectEl.addEventListener("change", async () => {
|
|
prefs.sort = sortSelectEl.value;
|
|
await savePrefs();
|
|
render();
|
|
});
|
|
|
|
viewGridBtn.addEventListener("click", async () => {
|
|
prefs.view = "grid";
|
|
await savePrefs();
|
|
render();
|
|
});
|
|
viewListBtn.addEventListener("click", async () => {
|
|
prefs.view = "list";
|
|
await savePrefs();
|
|
render();
|
|
});
|
|
|
|
showArchivedBtn.addEventListener("click", async () => {
|
|
prefs.showArchived = !prefs.showArchived;
|
|
await savePrefs();
|
|
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;
|
|
if (changes[STORAGE_KEY]) {
|
|
items = changes[STORAGE_KEY].newValue || [];
|
|
render();
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
})();
|