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"); let items = []; let prefs = { view: "grid", sort: "newest", showArchived: false }; let toastTimer = null; // 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] || {}) }; } async function savePrefs() { await chrome.storage.local.set({ [PREFS_KEY]: prefs }); } async function saveItems() { await chrome.storage.local.set({ [STORAGE_KEY]: items }); } 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.onclick = () => { actionFn(); toastEl.hidden = true; }; toastEl.appendChild(btn); } toastEl.hidden = false; toastTimer = setTimeout(() => (toastEl.hidden = true), 5000); } function applyViewButtons() { 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); } 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) { const card = document.createElement("div"); card.className = "card"; card.dataset.id = item.id; if (prefs.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 (prefs.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 (item.archived) { const badge = document.createElement("span"); badge.className = "archived-badge"; badge.textContent = "archived"; meta.appendChild(badge); } body.appendChild(meta); if (prefs.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 (prefs.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; 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); await saveItems(); render(); showToast("Removed from shelf.", "Undo", async () => { items.splice(idx, 0, removed); await saveItems(); render(); }); } 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); 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; } // --- 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(); }); // 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(); } }); (async function init() { await loadAll(); render(); })();