Files

724 lines
22 KiB
JavaScript
Raw Permalink Normal View History

2026-07-13 11:11:24 +10:00
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");
2026-07-14 12:44:17 +10:00
const tagsBtn = document.getElementById("tagsBtn");
const tagsPanel = document.getElementById("tagsPanel");
const tagsListEl = document.getElementById("tagsList");
const tagsEmptyHintEl = document.getElementById("tagsEmptyHint");
2026-07-13 11:11:24 +10:00
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,
};
2026-07-13 11:11:24 +10:00
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;
}
2026-07-13 11:11:24 +10:00
// 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" });
}
2026-07-14 12:44:17 +10:00
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();
}
2026-07-13 11:11:24 +10:00
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();
2026-07-13 11:11:24 +10:00
}
async function savePrefs() {
await chrome.storage.local.set({ [PREFS_KEY]: prefs });
}
async function saveItems() {
await chrome.storage.local.set({ [STORAGE_KEY]: items });
}
2026-07-14 12:44:17 +10:00
// 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 });
}
2026-07-13 11:11:24 +10:00
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";
2026-07-13 11:11:24 +10:00
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;
2026-07-13 11:11:24 +10:00
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
);
2026-07-13 11:11:24 +10:00
}
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) {
2026-07-13 11:11:24 +10:00
const card = document.createElement("div");
card.className = "card";
if (isDue(item)) card.classList.add("card-due");
2026-07-13 11:11:24 +10:00
card.dataset.id = item.id;
if (view === "list") {
2026-07-13 11:11:24 +10:00
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) {
2026-07-13 11:11:24 +10:00
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);
}
2026-07-13 11:11:24 +10:00
if (item.archived) {
const badge = document.createElement("span");
badge.className = "archived-badge";
badge.textContent = "archived";
meta.appendChild(badge);
}
body.appendChild(meta);
2026-07-14 12:44:17 +10:00
// 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") {
2026-07-13 11:11:24 +10:00
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") {
2026-07-13 11:11:24 +10:00
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;
}
2026-07-14 12:44:17 +10:00
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;
}
2026-07-13 11:11:24 +10:00
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);
2026-07-13 11:11:24 +10:00
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);
2026-07-13 11:11:24 +10:00
await saveItems();
render();
showToast("Removed from shelf.", "Undo", async () => {
removed.updatedAt = Date.now();
2026-07-13 11:11:24 +10:00
items.splice(idx, 0, removed);
items = await reconcileWithSync(items);
2026-07-13 11:11:24 +10:00
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
2026-07-14 12:44:17 +10:00
// 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);
}
2026-07-14 12:44:17 +10:00
// 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;
}
2026-07-14 12:44:17 +10:00
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;
}
2026-07-13 11:11:24 +10:00
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) ||
2026-07-14 12:44:17 +10:00
(i.url || "").toLowerCase().includes(query) ||
(i.tags || []).some((t) => t.includes(query))
2026-07-13 11:11:24 +10:00
);
}
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];
}
2026-07-13 11:11:24 +10:00
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();
2026-07-14 12:44:17 +10:00
renderTagsPanel();
2026-07-13 11:11:24 +10:00
}
// --- 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");
}
});
2026-07-14 12:44:17 +10:00
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();
2026-07-14 12:44:17 +10:00
if (!tagsPanel.hidden && !e.target.closest(".tags-wrap")) closeTagsPanel();
});
document.addEventListener("keydown", (e) => {
2026-07-14 12:44:17 +10:00
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();
});
2026-07-13 11:11:24 +10:00
// 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;
}
});
2026-07-13 11:11:24 +10:00
(async function init() {
await loadAll();
render();
await runAutoDeleteSweep();
await refreshSpotlightSelection();
renderSpotlight();
setInterval(runAutoDeleteSweep, 5 * 60 * 1000);
2026-07-13 11:11:24 +10:00
})();