- 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
724 lines
22 KiB
JavaScript
724 lines
22 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");
|
||
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);
|
||
})();
|