summaryrefslogtreecommitdiff
path: root/web/js/views
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
commit8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch)
tree5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /web/js/views
parent59c8870061465142306e7a6e5d3fcb344686cfe9 (diff)
Refine media browsing and set covers
Diffstat (limited to 'web/js/views')
-rw-r--r--web/js/views/help.js2
-rw-r--r--web/js/views/media-grid.js243
-rw-r--r--web/js/views/playback-nav.js6
-rw-r--r--web/js/views/sets.js83
-rw-r--r--web/js/views/tags.js52
5 files changed, 315 insertions, 71 deletions
diff --git a/web/js/views/help.js b/web/js/views/help.js
index 05d6949..a85c283 100644
--- a/web/js/views/help.js
+++ b/web/js/views/help.js
@@ -27,7 +27,5 @@ export function toggleHelp() {
}
export function showSearch() {
- const bar = document.getElementById('search-bar');
- bar?.classList.remove('hidden');
focusSearch();
}
diff --git a/web/js/views/media-grid.js b/web/js/views/media-grid.js
index 08435b8..4625eba 100644
--- a/web/js/views/media-grid.js
+++ b/web/js/views/media-grid.js
@@ -5,6 +5,33 @@ import { fmtSize } from '../dom.js';
import { escapeHtml, fmtDur, toast } from '../utils.js';
let callbacks = {};
+let mediaPageSize = 100;
+let lastMediaPageKey = '';
+
+export function setMediaPageSize(value) {
+ const parsed = Number.parseInt(value, 10);
+ mediaPageSize = Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
+ state.mediaPage = 0;
+}
+
+export function paginateItems(items, page, pageSize = mediaPageSize) {
+ const size = Math.max(1, Number.parseInt(pageSize, 10) || 100);
+ const total = items.length;
+ const maxPage = Math.max(0, Math.ceil(total / size) - 1);
+ const current = Math.max(0, Math.min(Number.parseInt(page, 10) || 0, maxPage));
+ const start = current * size;
+ const end = Math.min(start + size, total);
+ return {
+ items: items.slice(start, end),
+ total,
+ page: current,
+ pageSize: size,
+ start,
+ end,
+ hasPrev: current > 0,
+ hasNext: current < maxPage,
+ };
+}
export function initMediaGrid(options = {}) {
callbacks = options;
@@ -14,13 +41,11 @@ export function initMediaGrid(options = {}) {
const el = e.target.closest('.media-card, .media-row');
if (!el) return;
selectByElement(el);
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media?.type === 'image') {
- callbacks.openLightbox?.(state.media, media.id);
- } else {
- callbacks.playSelected?.();
+ if (el.classList.contains('set-card')) {
+ callbacks.openSet?.(parseInt(el.dataset.setId, 10));
+ return;
}
+ callbacks.playSelected?.();
});
grid?.addEventListener('click', (e) => {
@@ -51,13 +76,20 @@ export async function loadMedia() {
const setIds = state.selectedSetIds.length > 1 ? state.selectedSetIds.join(',') : '';
const singleSetId = state.selectedSetIds.length === 1 ? state.selectedSetIds[0] : null;
- if (singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) {
+ if (!singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) {
+ breadcrumb?.classList.add('hidden');
+ setMedia([]);
+ syncMediaPage('sets');
+ const page = renderSetGrid(state.sets || []);
+ resultCount.textContent = resultText(state.sets.length, page);
+ } else if (singleSetId && !setIds && !callbacks.isShuffle?.() && !hasActiveFilters()) {
+ syncMediaPage(`browse:${singleSetId}:${state.folderPath || ''}`);
const data = await API.browse(singleSetId, state.folderPath);
updateBreadcrumb(data.current_path);
setMedia(mediaWithBrowsePath(data.media || [], data.current_path || ''));
- renderBrowse(data);
+ const page = renderBrowse(data);
const total = (data.media?.length || 0) + (data.folders?.length || 0) + (data.episodes?.length || 0);
- resultCount.textContent = `${total} items`;
+ resultCount.textContent = resultText(total, page);
} else {
breadcrumb?.classList.add('hidden');
const sort = callbacks.isShuffle?.() ? 'random' : (state.filters.sort || 'name');
@@ -75,17 +107,29 @@ export async function loadMedia() {
sort,
limit: '1000',
};
+ syncMediaPage(`grid:${JSON.stringify(params)}`);
const data = await API.media(params);
const list = Array.isArray(data) ? data : data?.media || [];
setMedia(list);
- renderGrid(list);
- resultCount.textContent = `${list.length} items`;
+ const page = renderGrid(list);
+ resultCount.textContent = resultText(list.length, page);
}
} catch (err) {
grid.innerHTML = `<p class="error-message grid-full">${escapeHtml(err.message)}</p>`;
}
}
+function syncMediaPage(key) {
+ if (key === lastMediaPageKey) return;
+ lastMediaPageKey = key;
+ state.mediaPage = 0;
+}
+
+function resultText(total, page) {
+ if (!page || total <= page.pageSize) return `${total} items`;
+ return `${page.start + 1}-${page.end} of ${total} items`;
+}
+
function updateBreadcrumb(currentPath) {
const el = document.getElementById('breadcrumb-bar');
if (!el) return;
@@ -114,11 +158,31 @@ export function enterFolder(name) {
}
export function mediaWithBrowsePath(media, path) {
- return media.map((m) => ({ ...m, browse_path: path || '' }));
+ const prefix = path ? `${path}/` : '';
+ return media.map((m) => {
+ const relPath = m.rel_path || '';
+ const relativeToCurrent = prefix && relPath.startsWith(prefix)
+ ? relPath.slice(prefix.length)
+ : relPath;
+ return {
+ ...m,
+ browse_path: path || '',
+ flattened_folder: relativeToCurrent.includes('/'),
+ };
+ });
}
export function navigateBack() {
- if (!state.folderPath) return;
+ if (!state.folderPath) {
+ if (state.selectedSetId || state.selectedSetIds.length) {
+ state.selectedSetId = null;
+ state.selectedSetIds = [];
+ state.mediaPage = 0;
+ callbacks.onSetCleared?.();
+ loadMedia();
+ }
+ return;
+ }
const last = state.folderPath.lastIndexOf('/');
if (last < 0) {
state.folderPath = '';
@@ -128,6 +192,69 @@ export function navigateBack() {
loadMedia();
}
+function renderSetGrid(sets) {
+ const grid = document.getElementById('media-grid');
+ if (!grid) return;
+ if (!sets.length) {
+ const page = paginateItems([], state.mediaPage);
+ state.mediaPage = page.page;
+ grid.innerHTML = '<p class="text-muted text-sm grid-full">No sets.</p>';
+ clearSelection();
+ return page;
+ }
+ const entries = sets.map((item, index) => ({ item, index }));
+ const page = paginateItems(entries, state.mediaPage);
+ state.mediaPage = page.page;
+ grid.innerHTML = renderPager(page, 'top') +
+ page.items.map((entry) => renderSetCard(entry.item, entry.index)).join('') +
+ renderPager(page, 'bottom');
+ clearSelection();
+ bindSetCards(grid);
+ bindPager(grid);
+ return page;
+}
+
+function renderSetCard(set, index) {
+ return `
+ <div class="media-card set-card" data-set-id="${set.id}" data-index="${index}" tabindex="0" role="button" aria-label="Set ${escapeHtml(set.name)}">
+ <div class="thumb-wrap">
+ <img src="/api/sets/${set.id}/cover" alt="" loading="lazy" onerror="this.remove();">
+ <span class="placeholder">▦</span>
+ <div class="card-actions">
+ <button class="icon-btn btn-sm" data-action="regen-set-cover" title="Regenerate cover">🔄</button>
+ </div>
+ </div>
+ <div class="meta">
+ <div class="title">${escapeHtml(set.name)}</div>
+ <div class="subtitle">${set.is_podcast ? 'Podcast set' : 'Set'}</div>
+ </div>
+ </div>
+ `;
+}
+
+function bindSetCards(grid) {
+ grid.querySelectorAll('.set-card').forEach((el) => {
+ el.addEventListener('click', (e) => {
+ selectByElement(el);
+ if (e.target.closest('.card-actions, button')) return;
+ e.stopPropagation();
+ callbacks.openSet?.(parseInt(el.dataset.setId, 10));
+ });
+ el.querySelector('[data-action="regen-set-cover"]')?.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const id = parseInt(el.dataset.setId, 10);
+ if (!Number.isFinite(id)) return;
+ try {
+ await API.regenCover(id);
+ toast('Set cover regenerated');
+ await loadMedia();
+ } catch (err) {
+ toast(err.message || 'Cover failed', 'error');
+ }
+ });
+ });
+}
+
function renderBrowse(data) {
const grid = document.getElementById('media-grid');
if (!grid) return;
@@ -135,13 +262,31 @@ function renderBrowse(data) {
const media = mediaWithBrowsePath(data.media || [], data.current_path || '');
const episodes = data.episodes || [];
if (!folders.length && !media.length && !episodes.length) {
+ const page = paginateItems([], state.mediaPage);
+ state.mediaPage = page.page;
grid.innerHTML = '<p class="text-muted text-sm grid-full">Folder is empty.</p>';
clearSelection();
- return;
+ return page;
}
- const folderHtml = folders.map((f, i) => renderFolder(f, i)).join('');
- const mediaHtml = media.map((m, i) => renderItem(m, i)).join('');
- grid.innerHTML = folderHtml + mediaHtml;
+ const entries = [
+ ...folders.map((folder, index) => ({ type: 'folder', item: folder, index })),
+ ...media.map((item, index) => ({ type: 'media', item, index })),
+ ...episodes.map((item, index) => ({ type: 'episode', item, index })),
+ ];
+ const page = paginateItems(entries, state.mediaPage);
+ state.mediaPage = page.page;
+ const folderHtml = page.items
+ .filter((entry) => entry.type === 'folder')
+ .map((entry) => renderFolder(entry.item, entry.index))
+ .join('');
+ const mediaHtml = page.items
+ .filter((entry) => entry.type === 'media')
+ .map((entry) => renderItem(entry.item, entry.index))
+ .join('');
+ const pageEpisodes = page.items
+ .filter((entry) => entry.type === 'episode')
+ .map((entry) => entry.item);
+ grid.innerHTML = renderPager(page, 'top') + folderHtml + mediaHtml + renderPager(page, 'bottom');
clearSelection();
grid.querySelectorAll('.folder-card [data-action="regen-folder-cover"]').forEach((b) => {
@@ -168,12 +313,16 @@ function renderBrowse(data) {
});
bindMediaItems(grid);
+ bindPager(grid);
- if (episodes.length) {
+ if (pageEpisodes.length) {
import('../podcasts.js').then(m => {
- m.renderPodcastEpisodes(grid, episodes);
+ m.renderPodcastEpisodes(grid, pageEpisodes, {
+ before: grid.querySelector('[data-page-pager="bottom"]'),
+ });
}).catch(() => {});
}
+ return page;
}
function renderFolder(folder, index) {
@@ -202,18 +351,55 @@ function renderGrid(items) {
const grid = document.getElementById('media-grid');
if (!grid) return;
if (!items.length) {
+ const page = paginateItems([], state.mediaPage);
+ state.mediaPage = page.page;
grid.innerHTML = `<p class="text-muted text-sm grid-full">No results.</p>`;
clearSelection();
- return;
+ return page;
}
- grid.innerHTML = items.map((m, i) => renderItem(m, i)).join('');
+ const entries = items.map((item, index) => ({ item, index }));
+ const page = paginateItems(entries, state.mediaPage);
+ state.mediaPage = page.page;
+ grid.innerHTML = renderPager(page, 'top') +
+ page.items.map((entry) => renderItem(entry.item, entry.index)).join('') +
+ renderPager(page, 'bottom');
clearSelection();
bindMediaItems(grid);
+ bindPager(grid);
+ return page;
+}
+
+function renderPager(page, position) {
+ if (!page || page.total <= page.pageSize) return '';
+ const showPrev = position === 'top' && page.hasPrev;
+ const showNext = position === 'bottom' && page.hasNext;
+ if (!showPrev && !showNext) return '';
+ const action = showPrev ? 'prev' : 'next';
+ const label = showPrev ? 'Prev' : 'Next';
+ return `
+ <div class="media-pager media-pager-${position} grid-full" data-page-pager="${position}">
+ <span class="media-page-summary">${page.start + 1}-${page.end} of ${page.total}</span>
+ <button class="btn btn-ghost btn-sm" type="button" data-page-action="${action}">${label}</button>
+ </div>
+ `;
+}
+
+function bindPager(grid) {
+ grid.querySelectorAll('[data-page-action]').forEach((button) => {
+ button.addEventListener('click', () => {
+ state.mediaPage += button.dataset.pageAction === 'next' ? 1 : -1;
+ loadMedia();
+ });
+ });
}
function bindMediaItems(grid) {
grid.querySelectorAll('.media-card, .media-row').forEach((el) => {
- el.addEventListener('click', () => { selectByElement(el); });
+ el.addEventListener('click', (e) => {
+ selectByElement(el);
+ if (el.dataset.flattenedFolder !== 'true' || e.target.closest('.card-actions, button')) return;
+ callbacks.playSelected?.();
+ });
const playBtn = el.querySelector('[data-action="play"]');
const viewBtn = el.querySelector('[data-action="view"]');
const favBtn = el.querySelector('[data-action="favorite"]');
@@ -229,9 +415,7 @@ function bindMediaItems(grid) {
viewBtn?.addEventListener('click', (e) => {
e.stopPropagation();
selectByElement(el);
- const idx = parseInt(el.dataset.index, 10);
- const media = state.media[idx];
- if (media) callbacks.openLightbox?.(state.media, media.id);
+ callbacks.playSelected?.();
});
favBtn?.addEventListener('click', (e) => {
e.stopPropagation();
@@ -258,9 +442,10 @@ function bindMediaItems(grid) {
function renderItem(m, index) {
const sizeText = fmtSize(m.file_size_bytes);
+ const flattenedFolder = m.flattened_folder ? 'true' : 'false';
if (m.type === 'video') {
return `
- <div class="media-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
+ <div class="media-card" data-id="${m.id}" data-index="${index}" data-flattened-folder="${flattenedFolder}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
<div class="thumb-wrap">
${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`}
<span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span>
@@ -284,12 +469,12 @@ function renderItem(m, index) {
const resText = escapeHtml(m.resolution || '');
const safeSizeText = escapeHtml(sizeText || '');
return `
- <div class="media-card image-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
+ <div class="media-card image-card" data-id="${m.id}" data-index="${index}" data-flattened-folder="${flattenedFolder}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
<div class="thumb-wrap">
${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No image</span>`}
<span class="badge">${resText}${safeSizeText ? ' • ' + safeSizeText : ''}</span>
<div class="card-actions">
- <button class="icon-btn btn-sm" data-action="view" title="View">👁</button>
+ <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button>
<button class="icon-btn btn-sm" data-action="favorite" title="Favorite">♥</button>
<button class="icon-btn btn-sm" data-action="notes" title="Notes">📝</button>
<button class="icon-btn btn-sm" data-action="download" title="Download">⬇</button>
@@ -305,7 +490,7 @@ function renderItem(m, index) {
`;
}
return `
- <div class="media-card audio-card" data-id="${m.id}" data-index="${index}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
+ <div class="media-card audio-card" data-id="${m.id}" data-index="${index}" data-flattened-folder="${flattenedFolder}" tabindex="0" role="button" aria-label="${escapeHtml(m.file_name)}">
<div class="thumb-wrap">
${m.thumbnail_path ? `<img src="/api/media/${m.id}/thumbnail" alt="" loading="lazy">` : `<span class="placeholder">No cover</span>`}
<span class="badge">${fmtDur(m.duration)}${sizeText ? ' • ' + sizeText : ''}</span>
diff --git a/web/js/views/playback-nav.js b/web/js/views/playback-nav.js
index dfb959d..eb581db 100644
--- a/web/js/views/playback-nav.js
+++ b/web/js/views/playback-nav.js
@@ -22,7 +22,11 @@ export function initPlaybackNav({ isShuffle } = {}) {
}
export async function playSelected() {
- const el = currentElement();
+ let el = currentElement();
+ if (!el) {
+ el = document.querySelector('#media-grid .media-card[data-id], #media-grid .media-row[data-id]');
+ if (el) selectByElement(el);
+ }
if (!el) return;
const idx = parseInt(el.dataset.index, 10);
const media = state.media[idx];
diff --git a/web/js/views/sets.js b/web/js/views/sets.js
index 6badcb3..1287fe9 100644
--- a/web/js/views/sets.js
+++ b/web/js/views/sets.js
@@ -10,38 +10,53 @@ export function initSets({ onLoadMedia } = {}) {
export function renderSets() {
const el = document.getElementById('set-list');
- if (!el) return;
- el.innerHTML = state.sets.map((s) =>
- `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}">
- <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span>
- <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button>
- </div>`
- ).join('');
- el.querySelectorAll('.set-row').forEach((row) => {
- row.addEventListener('click', (e) => {
- if (e.target.closest('[data-cover-set]')) return;
- e.preventDefault();
- const id = parseInt(row.dataset.id, 10);
- state.selectedSetIds = [id];
- state.selectedSetId = id;
- state.folderPath = '';
- updateSetRowsUI();
- loadMediaCallback();
+ if (el) {
+ el.innerHTML = state.sets.map((s) =>
+ `<div class="set-row" tabindex="0" role="button" data-id="${s.id}" aria-label="Set ${escapeHtml(s.name)}">
+ <span class="set-item flex-1-18" data-id="${s.id}">${escapeHtml(s.name)}</span>
+ <button class="icon-btn btn-sm" data-cover-set="${s.id}" title="Regenerate cover">🔄</button>
+ </div>`
+ ).join('');
+ el.querySelectorAll('.set-row').forEach((row) => {
+ row.addEventListener('click', (e) => {
+ if (e.target.closest('[data-cover-set]')) return;
+ e.preventDefault();
+ selectSet(parseInt(row.dataset.id, 10));
+ });
});
- });
- el.querySelectorAll('[data-cover-set]').forEach((b) => {
- b.addEventListener('click', async (e) => {
- e.stopPropagation();
- const id = b.dataset.coverSet;
- try {
- await API.regenCover(id);
- toast('Cover regenerated');
- } catch (err) {
- toast(err.message || 'Cover failed', 'error');
- }
+ el.querySelectorAll('[data-cover-set]').forEach((b) => {
+ b.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const id = b.dataset.coverSet;
+ try {
+ await API.regenCover(id);
+ toast('Cover regenerated');
+ } catch (err) {
+ toast(err.message || 'Cover failed', 'error');
+ }
+ });
});
- });
+ }
+ updateSetRowsUI();
+}
+
+export function selectSet(id) {
+ if (!Number.isFinite(id)) return false;
+ if (!state.sets.some((set) => set.id === id)) return false;
+ state.selectedSetIds = [id];
+ state.selectedSetId = id;
+ state.folderPath = '';
updateSetRowsUI();
+ loadMediaCallback();
+ return true;
+}
+
+export function selectSetByHotkey(key) {
+ const index = parseInt(key, 10) - 1;
+ if (!Number.isFinite(index) || index < 0 || index >= 9 || index >= state.sets.length) {
+ return false;
+ }
+ return selectSet(state.sets[index].id);
}
export function updateSetRowsUI() {
@@ -76,18 +91,10 @@ export function toggleSetSelection() {
loadMediaCallback();
}
-function setActiveSet(id) {
- document.querySelectorAll('.set-item').forEach((a) => a.classList.toggle('active', parseInt(a.dataset.id, 10) === id));
-}
-
export function setSetByDelta(dx) {
if (!state.sets.length) return;
let idx = state.sets.findIndex((s) => s.id === state.selectedSetId);
if (idx < 0) idx = 0;
const nextIdx = (idx + dx + state.sets.length) % state.sets.length;
- state.selectedSetId = state.sets[nextIdx].id;
- state.selectedSetIds = [state.selectedSetId];
- state.folderPath = '';
- setActiveSet(state.selectedSetId);
- loadMediaCallback();
+ selectSet(state.sets[nextIdx].id);
}
diff --git a/web/js/views/tags.js b/web/js/views/tags.js
index 7d3ceba..1d7d29f 100644
--- a/web/js/views/tags.js
+++ b/web/js/views/tags.js
@@ -1,10 +1,14 @@
import { API } from '../api.js';
import { currentElement } from '../selection.js';
+import { state } from '../state.js';
import { escapeHtml, toast } from '../utils.js';
let tagsCurrentMediaId = null;
+let tagFilterCallback = () => {};
+let cachedTags = [];
-export function initTags() {
+export function initTags(options = {}) {
+ tagFilterCallback = typeof options.onFilterChange === 'function' ? options.onFilterChange : (() => {});
document.getElementById('tags-close')?.addEventListener('click', closeTagsModal);
document.getElementById('tags-modal')?.addEventListener('click', (e) => {
if (e.target === document.getElementById('tags-modal')) closeTagsModal();
@@ -16,6 +20,8 @@ export function initTags() {
addTagForSelected();
}
});
+ document.addEventListener('filters:changed', renderTagFilter);
+ refreshTagFilter();
}
export async function openTagsForSelected() {
@@ -56,6 +62,7 @@ function renderTagsList(tags) {
await API.removeTag(tagsCurrentMediaId, b.dataset.tag);
const detail = await API.mediaDetail(tagsCurrentMediaId);
renderTagsList(detail?.tags || []);
+ refreshTagFilter();
} catch (err) {
toast(err.message || 'Remove tag failed', 'error');
}
@@ -73,7 +80,50 @@ async function addTagForSelected() {
input.value = '';
const detail = await API.mediaDetail(tagsCurrentMediaId);
renderTagsList(detail?.tags || []);
+ refreshTagFilter();
} catch (err) {
toast(err.message || 'Add tag failed', 'error');
}
}
+
+async function refreshTagFilter() {
+ const el = document.getElementById('tag-filter-list');
+ if (!el) return;
+ try {
+ cachedTags = await API.tags();
+ renderTagFilter();
+ } catch {
+ cachedTags = [];
+ renderTagFilter();
+ }
+}
+
+function renderTagFilter() {
+ const el = document.getElementById('tag-filter-list');
+ if (!el) return;
+ const active = selectedTags();
+ el.innerHTML = (cachedTags || []).map((tag) => {
+ const name = tag.name || '';
+ const isActive = active.includes(name);
+ return `<button type="button" class="tag-filter-chip${isActive ? ' active' : ''}" data-tag="${escapeHtml(name)}" title="Filter tag ${escapeHtml(name)}"><span>#${escapeHtml(name)}</span></button>`;
+ }).join('');
+ el.querySelectorAll('.tag-filter-chip').forEach((button) => {
+ button.addEventListener('click', () => toggleTagFilter(button.dataset.tag));
+ });
+}
+
+function toggleTagFilter(name) {
+ if (!name) return;
+ const tags = selectedTags();
+ const next = tags.includes(name)
+ ? tags.filter((tag) => tag !== name)
+ : [...tags, name];
+ state.filters.tags = next.join(',');
+ state.folderPath = '';
+ renderTagFilter();
+ tagFilterCallback();
+}
+
+function selectedTags() {
+ return (state.filters.tags || '').split(',').map((tag) => tag.trim()).filter(Boolean);
+}