From d627e6bb4445fa5206ee6d5df9e82801afb974e3 Mon Sep 17 00:00:00 2001 From: Stefan Willoughby Date: Mon, 13 Jul 2026 11:11:24 +1000 Subject: [PATCH] Initial commit: Shelf read-later extension --- README.md | 59 +++++++++ background.js | 113 +++++++++++++++++ icons/icon128.png | Bin 0 -> 766 bytes icons/icon16.png | Bin 0 -> 138 bytes icons/icon32.png | Bin 0 -> 220 bytes icons/icon48.png | Bin 0 -> 300 bytes manifest.json | 34 +++++ newtab.css | 315 ++++++++++++++++++++++++++++++++++++++++++++++ newtab.html | 53 ++++++++ newtab.js | 302 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 876 insertions(+) create mode 100644 README.md create mode 100644 background.js create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon32.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 newtab.css create mode 100644 newtab.html create mode 100644 newtab.js 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 0000000000000000000000000000000000000000..9709d6d295e5d8c669fa0f915dbc010b5ae4b859 GIT binary patch literal 766 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU^?pQ;uumf=j~lbzsCk54G)(* zQtllqDtq{`-IXELIo6_usu8luPGnRaMpfSy5Tr zboryr^*wbLyRAR`@?sBslEk;KSIF?qu7>9Khvx~H)h=#+fBqWBru`=$e%Cc`aXu$* z{pa7)a~dnf*zM^RS0^H=6{w_lsDc>471>eta8kJuWF87!CwoTy<~T(c)y_i#;K zLgwj%@&9aZr3+7MxWkaWeBKV;0BMEP16y(%ZZhm#K6?}IA@&cSzU`}=(Zb=x9+1>f z%_QY`f+34XH;d&V(+bNK9k&&H81`Gv@epj`Sa8%~k&D5X`;1Ziiihs=wB2P`+rIzG z<-Jd&@|eH;IxJTz&9jxUO7`C+j_*56>}~Dqw(R5FYJ7z)pb{Kt?Z9~54iqW0nwM_Q zFb5p0iL?IMb4T6psJou}EDscPzj+QEXEhpZvY~Yr#9Ki)|O$ZD;R1;bq=p=4CUf+x~Lz!gZJ4Cx*tq>Pg&x dOaI<~hWSUYD7$pGujo=R*xYOs{V^W*f++kbNYb l<7j2$BZG!?@1o7d3{kBzTy+^rkwC*4JYD@<);T3K0RWLzz8^XjM7jp>j)pg(A-Fa`?V(X^P2~JVW#~MG_8cBQ*NKg!Wct}8Z5>H=z zaj!-xvmDc6K8ex;E12D64PG?_vh(ao?cIp5aYc3yjOh(OaP4upfLe7CV<8S(3k)k6F_4EXiNZ&3FH{(rRszI3e5z< z;4MYO1#)3qNQeqt2`DX5b+G06Hk4m4aWGchftrd+bi+=k(QxS=wr93=cltX(RK5-G y5uaIQ2f~8$*`Ov={c3Pl + + + +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(); +})();