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
This commit is contained in:
2026-07-14 12:44:17 +10:00
parent c5bd7e38bf
commit 232524ad97
10 changed files with 446 additions and 49 deletions
+165 -5
View File
@@ -24,6 +24,10 @@ 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 = {
@@ -72,6 +76,51 @@ function timeAgo(ts) {
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] || [];
@@ -88,7 +137,7 @@ async function saveItems() {
await chrome.storage.local.set({ [STORAGE_KEY]: items });
}
// Kept out of shelfPrefs deliberately this is derived runtime state (which
// 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);
@@ -233,6 +282,12 @@ function buildCard(item, view = prefs.view) {
}
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";
@@ -255,6 +310,77 @@ function buildCard(item, view = prefs.view) {
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";
@@ -310,7 +436,7 @@ async function deleteItem(id) {
// 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
// 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;
@@ -361,7 +487,7 @@ async function refreshSpotlightSelection() {
await saveSpotlightState(spotlightState);
}
// Pure projection of the current selection against live item state — no
// 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.
@@ -380,6 +506,19 @@ function renderSpotlight() {
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));
@@ -389,7 +528,8 @@ function getFilteredSorted() {
(i) =>
(i.title || "").toLowerCase().includes(query) ||
(i.domain || "").toLowerCase().includes(query) ||
(i.url || "").toLowerCase().includes(query)
(i.url || "").toLowerCase().includes(query) ||
(i.tags || []).some((t) => t.includes(query))
);
}
@@ -426,6 +566,7 @@ function render() {
shelfEl.hidden = items.length === 0;
renderSpotlight();
renderTagsPanel();
}
// --- Event wiring ---
@@ -470,12 +611,31 @@ settingsBtn.addEventListener("click", (e) => {
}
});
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" && !settingsPanel.hidden) closeSettingsPanel();
if (e.key !== "Escape") return;
if (!settingsPanel.hidden) closeSettingsPanel();
if (!tagsPanel.hidden) closeTagsPanel();
});
reminderEnabledEl.addEventListener("change", async () => {