Files
read-it-later/newtab.js
T
stefwill 232524ad97 feat: add tags/labels for saved items
- Add free-form tags per item: chip display with remove, inline add (comma-separated, grid view only), and a toolbar tag-filter popover listing every tag in use
- Search now also matches tags; tags sync across devices like every other item field
- Fix Firefox manifest.json compliance for AMO submission: replace the placeholder gecko id with a real UUID, add the now-required data_collection_permissions (bookmarksInfo, since synced items are personal browsing/bookmark data), and bump strict_min_version to 140.0 (142.0 for gecko_android) to match
- Add description.md (elevator pitch / short / detailed descriptions for store listings), PRIVACY.md, and .gitignore for the dist/ packaging output
- Reword all em-dash punctuation across docs and code comments per user preference
2026-07-14 12:44:17 +10:00

724 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
})();