303 lines
9.0 KiB
JavaScript
303 lines
9.0 KiB
JavaScript
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();
|
||
|
|
})();
|