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"); const tagsBtn = document.getElementById("tagsBtn"); const tagsPanel = document.getElementById("tagsPanel"); const tagsListEl = document.getElementById("tagsList"); const tagsEmptyHintEl = document.getElementById("tagsEmptyHint"); 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" }); } function normalizeTag(raw) { return raw.trim().toLowerCase().replace(/\s+/g, " "); } // Supports comma-separated entry ("css, research") so one Enter press can add several. function parseTagInput(raw) { return [...new Set(raw.split(",").map(normalizeTag).filter(Boolean))]; } function allTags() { const set = new Set(); items.forEach((item) => (item.tags || []).forEach((t) => set.add(t))); return [...set].sort(); } function filterByTag(tag) { searchEl.value = tag; closeTagsPanel(); render(); } async function addTagsToItem(id, raw) { const it = items.find((i) => i.id === id); if (!it) return; const toAdd = parseTagInput(raw); if (!toAdd.length) return; const existing = new Set(it.tags || []); toAdd.forEach((t) => existing.add(t)); it.tags = [...existing]; it.updatedAt = Date.now(); items = await reconcileWithSync(items); await saveItems(); render(); } async function removeTagFromItem(id, tag) { const it = items.find((i) => i.id === id); if (!it) return; it.tags = (it.tags || []).filter((t) => t !== tag); it.updatedAt = Date.now(); items = await reconcileWithSync(items); await saveItems(); render(); } 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); // Tag editing needs real vertical room, so it's grid-only. List view stays // the compact bookshelf-spine row it's meant to be. if (view === "grid") { body.appendChild(buildTagsRow(item)); } 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 buildTagsRow(item) { const row = document.createElement("div"); row.className = "card-tags"; (item.tags || []).forEach((tag) => { const chip = document.createElement("span"); chip.className = "tag-chip"; const label = document.createElement("span"); label.className = "tag-chip-label"; label.textContent = tag; label.title = `Filter by "${tag}"`; label.addEventListener("click", () => filterByTag(tag)); chip.appendChild(label); const remove = document.createElement("button"); remove.className = "tag-remove"; remove.textContent = "×"; remove.title = `Remove tag "${tag}"`; remove.addEventListener("click", (e) => { e.preventDefault(); removeTagFromItem(item.id, tag); }); chip.appendChild(remove); row.appendChild(chip); }); const addBtn = document.createElement("button"); addBtn.className = "tag-add-btn"; addBtn.textContent = "+ tag"; addBtn.addEventListener("click", (e) => { e.preventDefault(); addBtn.replaceWith(buildTagInput(item)); }); row.appendChild(addBtn); return row; } function buildTagInput(item) { const input = document.createElement("input"); input.type = "text"; input.className = "tag-input"; input.placeholder = "tag, another…"; // Guards against double-submit: committing rebuilds the shelf (via render()), // which removes this input from the DOM, and removing a focused element // fires "blur", which would otherwise re-trigger commit() a second time. let done = false; const commit = () => { if (done) return; done = true; if (input.value.trim()) addTagsToItem(item.id, input.value); else render(); }; input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { done = true; render(); } }); input.addEventListener("blur", commit); setTimeout(() => input.focus(), 0); return input; } 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 renderTagsPanel() { const tags = allTags(); tagsListEl.innerHTML = ""; tags.forEach((tag) => { const chip = document.createElement("button"); chip.className = "tag-chip tag-chip-filter"; chip.textContent = tag; chip.addEventListener("click", () => filterByTag(tag)); tagsListEl.appendChild(chip); }); tagsEmptyHintEl.hidden = tags.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) || (i.tags || []).some((t) => t.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(); renderTagsPanel(); } // --- 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"); } }); function closeTagsPanel() { tagsPanel.hidden = true; tagsBtn.setAttribute("aria-expanded", "false"); } tagsBtn.addEventListener("click", (e) => { e.stopPropagation(); const isOpen = !tagsPanel.hidden; if (isOpen) { closeTagsPanel(); } else { tagsPanel.hidden = false; tagsBtn.setAttribute("aria-expanded", "true"); } }); document.addEventListener("click", (e) => { if (!settingsPanel.hidden && !e.target.closest(".settings-wrap")) closeSettingsPanel(); if (!tagsPanel.hidden && !e.target.closest(".tags-wrap")) closeTagsPanel(); }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (!settingsPanel.hidden) closeSettingsPanel(); if (!tagsPanel.hidden) closeTagsPanel(); }); 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); })();