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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user