Initial commit: Shelf read-later extension
This commit is contained in:
@@ -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`.
|
||||||
+113
@@ -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, "✓");
|
||||||
|
}
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 766 B |
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
Binary file not shown.
|
After Width: | Height: | Size: 220 B |
Binary file not shown.
|
After Width: | Height: | Size: 300 B |
@@ -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": "[email protected]",
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
@@ -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; }
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Shelf</title>
|
||||||
|
<link rel="stylesheet" href="newtab.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="masthead-title">
|
||||||
|
<span class="mark" aria-hidden="true">🔖</span>
|
||||||
|
<h1>Shelf</h1>
|
||||||
|
</div>
|
||||||
|
<p class="tally"><span id="unreadCount">0</span> saved for later<span id="archivedNote"></span></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<input id="search" type="text" placeholder="Search your shelf…" autocomplete="off" />
|
||||||
|
|
||||||
|
<div class="control-group" role="group" aria-label="Sort by">
|
||||||
|
<label for="sortSelect" class="sr-only">Sort by</label>
|
||||||
|
<select id="sortSelect">
|
||||||
|
<option value="newest">Newest first</option>
|
||||||
|
<option value="oldest">Oldest first</option>
|
||||||
|
<option value="title">Title, A–Z</option>
|
||||||
|
<option value="domain">Site, A–Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group view-toggle" role="group" aria-label="View">
|
||||||
|
<button id="viewGridBtn" class="toggle-btn" data-view="grid" title="Grid view" aria-pressed="false">▦</button>
|
||||||
|
<button id="viewListBtn" class="toggle-btn" data-view="list" title="List view" aria-pressed="false">☰</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="showArchivedBtn" class="ghost-btn">Show archived</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main id="shelf" class="shelf" data-view="grid">
|
||||||
|
<!-- items injected here -->
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="emptyState" class="empty-state" hidden>
|
||||||
|
<p class="empty-title">Your shelf is empty.</p>
|
||||||
|
<p class="empty-sub">Click the Shelf icon in your toolbar, or right-click any page or link, to save something for later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast" hidden></div>
|
||||||
|
|
||||||
|
<script src="newtab.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user