commit d627e6bb4445fa5206ee6d5df9e82801afb974e3 Author: Stefan Willoughby Date: Mon Jul 13 11:11:24 2026 +1000 Initial commit: Shelf read-later extension diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a6c394 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Shelf — Read Later + +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 +browser's local extension storage. + +## How to save something + +- **Click the toolbar icon** — saves the page you're currently on. +- **Right-click a page** → "Save page for later" +- **Right-click a link** → "Save link for later" (doesn't need to be open) + +A ✓ briefly appears on the icon to confirm the save. + +## The shelf (new tab page) + +- Toggle between **grid** (cards with thumbnails) and **list** (compact, + bookshelf-spine style) — top right. +- **Sort** by newest, oldest, title, or site. +- **Search** filters by title, domain, or URL as you type. +- **Archive** tucks an item away without deleting it; "Show archived" + reveals archived items again. +- **Delete** removes an item, with a 5-second **Undo**. + +Thumbnails are captured automatically when you save the *current* page +(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. + +## Install — Chrome / Edge (unpacked) + +1. Go to `chrome://extensions` (or `edge://extensions`). +2. Turn on **Developer mode** (top right). +3. Click **Load unpacked** and select this folder. +4. Open a new tab — you should see the Shelf page. + +## Install — Firefox (temporary, for testing) + +1. Go to `about:debugging#/runtime/this-firefox`. +2. Click **Load Temporary Add-on…** +3. Select the `manifest.json` file inside this folder. +4. Open a new tab. Firefox will ask permission to let the extension + override the new tab page the first time — allow it. + +> Temporary add-ons in Firefox are removed when you close the browser. +> For a permanent install, the extension needs to be signed by Mozilla +> (via [addons.mozilla.org](https://addons.mozilla.org)) or loaded in a +> [Developer Edition / Nightly build](https://www.mozilla.org/firefox/developer/) +> with `xpinstall.signatures.required` set to `false`. + +## 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 + any time; sizes 16/32/48/128 are already wired up in `manifest.json`. diff --git a/background.js b/background.js new file mode 100644 index 0000000..adff3c5 --- /dev/null +++ b/background.js @@ -0,0 +1,113 @@ +// Shelf — background service worker +// Works under both the `chrome` namespace (Chrome/Edge) and Firefox's +// chrome.* alias for WebExtensions. + +const STORAGE_KEY = "shelfItems"; + +function domainOf(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +async function getItems() { + const data = await chrome.storage.local.get(STORAGE_KEY); + return data[STORAGE_KEY] || []; +} + +async function setItems(items) { + await chrome.storage.local.set({ [STORAGE_KEY]: items }); +} + +async function addItem({ url, title, favIconUrl, image }) { + if (!url) return; + const items = await getItems(); + + // De-dupe: if already saved, just bump it to the top and refresh metadata. + const existingIndex = items.findIndex((i) => i.url === url); + const entry = { + id: existingIndex >= 0 ? items[existingIndex].id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + url, + title: title || url, + domain: domainOf(url), + favicon: favIconUrl || "", + image: image || (existingIndex >= 0 ? items[existingIndex].image : ""), + dateAdded: Date.now(), + archived: false, + }; + + if (existingIndex >= 0) items.splice(existingIndex, 1); + items.unshift(entry); + await setItems(items); + return entry; +} + +async function flashBadge(tabId, text) { + try { + await chrome.action.setBadgeText({ text, tabId }); + await chrome.action.setBadgeBackgroundColor({ color: "#C47A4A", tabId }); + setTimeout(() => { + chrome.action.setBadgeText({ text: "", tabId }).catch(() => {}); + }, 1400); + } catch { + // tabId may be gone; ignore. + } +} + +// Try to grab a screenshot of the *currently active* tab. This only works +// for the page the user is actually looking at (browser security), so it's +// used when saving the current page, not for arbitrary right-clicked links. +async function captureScreenshot(windowId) { + try { + const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { + format: "jpeg", + quality: 55, + }); + return dataUrl; + } catch { + return ""; + } +} + +// --- Toolbar icon click: save the current page --- +chrome.action.onClicked.addListener(async (tab) => { + if (!tab || !tab.url || tab.url.startsWith("chrome://") || tab.url.startsWith("about:")) { + return; + } + const image = await captureScreenshot(tab.windowId); + await addItem({ url: tab.url, title: tab.title, favIconUrl: tab.favIconUrl, image }); + flashBadge(tab.id, "✓"); +}); + +// --- Context menus: right-click a page or a link --- +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.create({ + id: "save-page-later", + title: "Save page for later", + contexts: ["page"], + }); + chrome.contextMenus.create({ + id: "save-link-later", + title: "Save link for later", + contexts: ["link"], + }); +}); + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId === "save-page-later") { + if (!tab || !tab.url) return; + const image = await captureScreenshot(tab.windowId); + await addItem({ url: tab.url, title: tab.title, favIconUrl: tab.favIconUrl, image }); + flashBadge(tab.id, "✓"); + } + + if (info.menuItemId === "save-link-later") { + if (!info.linkUrl) return; + const linkTitle = info.linkText && info.linkText.trim() ? info.linkText.trim() : info.linkUrl; + // No screenshot for links we haven't navigated to — favicon/title only. + await addItem({ url: info.linkUrl, title: linkTitle, favIconUrl: "", image: "" }); + if (tab && tab.id) flashBadge(tab.id, "✓"); + } +}); diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000..9709d6d Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..4bc19f0 Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon32.png b/icons/icon32.png new file mode 100644 index 0000000..ba0663d Binary files /dev/null and b/icons/icon32.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..1da7c9c Binary files /dev/null and b/icons/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..81a060c --- /dev/null +++ b/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": 3, + "name": "Shelf — Read Later", + "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.", + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage"], + "background": { + "service_worker": "background.js", + "scripts": ["background.js"] + }, + "action": { + "default_title": "Save this page to Shelf", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png" + } + }, + "chrome_url_overrides": { + "newtab": "newtab.html" + }, + "browser_specific_settings": { + "gecko": { + "id": "shelf-read-later@example.local", + "strict_min_version": "109.0" + } + } +} diff --git a/newtab.css b/newtab.css new file mode 100644 index 0000000..e34a898 --- /dev/null +++ b/newtab.css @@ -0,0 +1,315 @@ +:root { + --paper: #EDEAE1; + --surface: #F8F6F0; + --ink: #211E19; + --muted: #756E60; + --hairline: #DBD6C7; + --clay: #C47A4A; + --clay-deep: #A8623A; + --moss: #55684B; + --shadow: 0 1px 2px rgba(33, 30, 25, 0.06), 0 6px 16px rgba(33, 30, 25, 0.05); +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--paper); + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, sans-serif; +} + +.sr-only { + position: absolute; + width: 1px; height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +} + +.page { + max-width: 1100px; + margin: 0 auto; + padding: 56px 32px 80px; +} + +/* --- Masthead --- */ +.masthead { + margin-bottom: 36px; +} +.masthead-title { + display: flex; + align-items: baseline; + gap: 12px; +} +.masthead-title .mark { + font-size: 26px; + transform: translateY(2px); +} +.masthead h1 { + font-family: Georgia, "Iowan Old Style", "Palatino Linotype", "Times New Roman", serif; + font-size: 40px; + font-weight: 600; + letter-spacing: -0.01em; + margin: 0; + color: var(--ink); +} +.tally { + margin: 6px 0 0; + color: var(--muted); + font-size: 14px; + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; +} +#archivedNote { margin-left: 6px; } + +/* --- Toolbar --- */ +.toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; + flex-wrap: wrap; +} + +#search { + flex: 1 1 240px; + min-width: 180px; + padding: 10px 14px; + border-radius: 8px; + border: 1px solid var(--hairline); + background: var(--surface); + font-size: 14px; + color: var(--ink); +} +#search:focus-visible { + outline: 2px solid var(--clay); + outline-offset: 1px; +} + +select#sortSelect { + padding: 9px 12px; + border-radius: 8px; + border: 1px solid var(--hairline); + background: var(--surface); + font-size: 13px; + color: var(--ink); + font-family: inherit; +} + +.control-group { display: flex; gap: 6px; } + +.toggle-btn, .ghost-btn { + border: 1px solid var(--hairline); + background: var(--surface); + color: var(--muted); + border-radius: 8px; + padding: 9px 12px; + font-size: 14px; + cursor: pointer; + line-height: 1; +} +.toggle-btn[aria-pressed="true"] { + color: var(--clay-deep); + border-color: var(--clay); + background: #FBEFE6; +} +.toggle-btn:focus-visible, .ghost-btn:focus-visible, select:focus-visible { + outline: 2px solid var(--clay); + outline-offset: 1px; +} +.ghost-btn { white-space: nowrap; font-family: inherit; } +.ghost-btn.active { + color: var(--clay-deep); + border-color: var(--clay); + background: #FBEFE6; +} + +/* --- Shelf: grid view (library index cards) --- */ +.shelf[data-view="grid"] { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 18px; +} + +.card { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: 10px; + overflow: hidden; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.card:hover { + transform: translateY(-2px); + box-shadow: 0 2px 4px rgba(33,30,25,0.08), 0 10px 22px rgba(33,30,25,0.08); +} + +.card-thumb { + height: 120px; + background: linear-gradient(135deg, #E4DFD0, #D8D2BF); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.card-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } +.card-thumb .favicon-fallback { + width: 34px; height: 34px; + border-radius: 6px; + background: var(--paper); + display: flex; align-items: center; justify-content: center; + font-family: Georgia, serif; + font-size: 16px; + color: var(--clay-deep); + border: 1px solid var(--hairline); +} + +.card-perf { + border-top: 1px dashed var(--hairline); +} + +.card-body { + padding: 12px 14px 14px; + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} +.card-title { + font-size: 14.5px; + font-weight: 600; + line-height: 1.35; + color: var(--ink); + text-decoration: none; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.card-title:hover { color: var(--clay-deep); } + +.card-meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11.5px; + color: var(--muted); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + margin-top: auto; +} + +.card-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} +.icon-btn { + border: 1px solid var(--hairline); + background: transparent; + border-radius: 6px; + font-size: 12px; + padding: 5px 9px; + color: var(--muted); + cursor: pointer; + font-family: inherit; +} +.icon-btn:hover { color: var(--ink); border-color: #C9C2AE; } +.icon-btn.danger:hover { color: #A8412D; border-color: #E3B7A9; } + +/* --- Shelf: list view (bookshelf spines) --- */ +.shelf[data-view="list"] { + display: flex; + flex-direction: column; + gap: 8px; +} +.shelf[data-view="list"] .card { + flex-direction: row; + align-items: stretch; + height: 64px; +} +.shelf[data-view="list"] .spine { + width: 6px; + flex-shrink: 0; +} +.shelf[data-view="list"] .card-thumb { + width: 64px; + height: 64px; + flex-shrink: 0; +} +.shelf[data-view="list"] .card-perf { + border-top: none; + border-left: 1px dashed var(--hairline); +} +.shelf[data-view="list"] .card-body { + flex-direction: row; + align-items: center; + gap: 14px; + padding: 0 14px; + flex: 1; + min-width: 0; +} +.shelf[data-view="list"] .card-title { + -webkit-line-clamp: 1; + flex: 1; + min-width: 0; +} +.shelf[data-view="list"] .card-meta { + flex-direction: column; + align-items: flex-end; + gap: 2px; + margin-top: 0; + white-space: nowrap; +} +.shelf[data-view="list"] .card-actions { + margin-top: 0; +} + +.archived-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 999px; + background: #E6E2D3; + color: var(--muted); + font-family: ui-monospace, monospace; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* --- Empty state --- */ +.empty-state { + text-align: center; + padding: 80px 20px; + color: var(--muted); +} +.empty-title { + font-family: Georgia, serif; + font-size: 20px; + color: var(--ink); + margin-bottom: 6px; +} +.empty-sub { font-size: 14px; max-width: 380px; margin: 0 auto; } + +/* --- Toast --- */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--ink); + color: var(--paper); + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + box-shadow: var(--shadow); +} + +@media (prefers-reduced-motion: reduce) { + .card { transition: none; } +} + +@media (max-width: 560px) { + .page { padding: 32px 16px 60px; } + .masthead h1 { font-size: 30px; } +} diff --git a/newtab.html b/newtab.html new file mode 100644 index 0000000..c8145ab --- /dev/null +++ b/newtab.html @@ -0,0 +1,53 @@ + + + + +Shelf + + + +
+
+
+ +

Shelf

+
+

0 saved for later

+
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ +
+ + +
+ + + + + + diff --git a/newtab.js b/newtab.js new file mode 100644 index 0000000..2f47b5b --- /dev/null +++ b/newtab.js @@ -0,0 +1,302 @@ +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(); +})();