diff options
Diffstat (limited to 'packages/meshbay-hub/src')
16 files changed, 158 insertions, 34 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js index af8879c..f109da7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-name.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-name.js @@ -1,4 +1,6 @@ import { html } from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { sourceLabel } from './source-merge.js'; /** * A group's name with the `@owner` handle under it. @@ -20,3 +22,36 @@ export function GroupName({ name, owner, inline = false }) { </span> `; } + +/** + * Which group serves an entry, or how many groups have it. + * + * The Search view merges a file several groups share into one entry, so the + * badge under a card cannot always name a group. One source keeps naming it + * and keeps linking to it; more than one becomes a count, and *which* one was + * picked is deliberately not shown (docs/refactoring-search.md §5.5). + * + * `entries` is the whole unit — every episode of a show, every track of an + * album — not the entry the card was drawn from; `sourceLabel` explains why. + * Renders nothing at all on the single-group Group page, where an entry + * carries no group and there is only ever one source anyway. + * + * It lives here rather than in `source-merge.js` because that module is + * executed standalone by its test and must keep importing nothing; and here + * rather than in one of the three apps that need it, because a copy apiece is + * three chances to disagree about what a merged card says. + * + * A `div` by default: the three card badges rely on `text-overflow: ellipsis`, + * which does nothing on an inline box. `link` gives the flat row's inline + * pill instead. + */ +export function SourceTag({ entries, cls, link = false }) { + const { count, name, groupId } = sourceLabel(entries); + if (count > 1) return html`<span class=${cls}>${t('search.n_sources', { n: count })}</span>`; + if (!name) return null; + if (link && groupId) { + return html`<a href="#/group/${groupId}" class=${cls} + onClick=${(e) => e.stopPropagation()}>${name}</a>`; + } + return html`<div class=${cls}>${name}</div>`; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index f118ffe..b66c847 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -616,6 +616,7 @@ export default { 'search.hint': 'Tippen Sie einen Begriff ein, um in allen Ihren Gruppen zu suchen.', 'search.indexing': 'Indexierung von {done} / {total} Gruppen …', 'search.unreachable': { one: '{n} Gruppe nicht erreichbar', other: '{n} Gruppen nicht erreichbar' }, + 'search.n_sources': { one: '{n} Quelle', other: '{n} Quellen' }, 'search.view_files': 'Dateien', 'search.view_videos': 'Videos', 'search.view_music': 'Musik', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index b55359b..898f7f4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -690,6 +690,7 @@ export default { 'search.hint': 'Type to search file names across all your groups.', 'search.indexing': 'Indexing {done} / {total} groups...', 'search.unreachable': { one: '{n} group unreachable', other: '{n} groups unreachable' }, + 'search.n_sources': { one: '{n} source', other: '{n} sources' }, 'search.view_files': 'Files', 'search.view_videos': 'Videos', 'search.view_music': 'Music', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 8e90dca..8d06a2d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -612,6 +612,7 @@ export default { 'search.hint': 'Escriba un término para buscar en todos sus grupos.', 'search.indexing': 'Indexando {done} / {total} grupos…', 'search.unreachable': { one: '{n} grupo inalcanzable', other: '{n} grupos inalcanzables' }, + 'search.n_sources': { one: '{n} fuente', other: '{n} fuentes' }, 'search.view_files': 'Archivos', 'search.view_videos': 'Vídeos', 'search.view_music': 'Música', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 995d4b3..1999e7e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -615,6 +615,7 @@ export default { 'search.hint': 'Saisissez un terme pour rechercher dans tous vos groupes.', 'search.indexing': 'Indexation de {done} / {total} groupes…', 'search.unreachable': { one: '{n} groupe injoignable', other: '{n} groupes injoignables' }, + 'search.n_sources': { one: '{n} source', other: '{n} sources' }, 'search.view_files': 'Fichiers', 'search.view_videos': 'Vidéos', 'search.view_music': 'Musique', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 467521f..52e18a8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -614,6 +614,7 @@ export default { 'search.hint': 'Digita un termine per cercare in tutti i tuoi gruppi.', 'search.indexing': 'Indicizzazione di {done} / {total} gruppi…', 'search.unreachable': { one: '{n} gruppo non raggiungibile', other: '{n} gruppi non raggiungibili' }, + 'search.n_sources': { one: '{n} fonte', other: '{n} fonti' }, 'search.view_files': 'File', 'search.view_videos': 'Video', 'search.view_music': 'Musica', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 89e8bdd..c0560b3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -602,6 +602,7 @@ export default { 'search.hint': '入力してすべてのグループを検索します。', 'search.indexing': '{done} / {total} グループをインデックス中…', 'search.unreachable': { other: '{n} グループに接続できません' }, + 'search.n_sources': { other: '{n} 個のソース' }, 'search.view_files': 'ファイル', 'search.view_videos': '動画', 'search.view_music': '音楽', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 1ed0362..da6dc04 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -616,6 +616,7 @@ export default { 'search.hint': 'Typ een zoekterm om in al uw groepen te zoeken.', 'search.indexing': '{done} / {total} groepen indexeren…', 'search.unreachable': { one: '{n} groep onbereikbaar', other: '{n} groepen onbereikbaar' }, + 'search.n_sources': { one: '{n} bron', other: '{n} bronnen' }, 'search.view_files': 'Bestanden', 'search.view_videos': 'Video\'s', 'search.view_music': 'Muziek', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index f71d048..29feebe 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -634,6 +634,7 @@ export default { 'search.hint': 'Wpisz frazę, aby wyszukać we wszystkich grupach.', 'search.indexing': 'Indeksowanie {done} / {total} grup…', 'search.unreachable': { one: '{n} grupa nieosiągalna', few: '{n} grupy nieosiągalne', many: '{n} grup nieosiągalnych', other: '{n} grupy nieosiągalnej' }, + 'search.n_sources': { one: '{n} źródło', few: '{n} źródła', many: '{n} źródeł', other: '{n} źródła' }, 'search.view_files': 'Pliki', 'search.view_videos': 'Filmy', 'search.view_music': 'Muzyka', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 1919edd..d2fc355 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -613,6 +613,7 @@ export default { 'search.hint': 'Digite um termo para pesquisar em todos os seus grupos.', 'search.indexing': 'Indexando {done} / {total} grupos…', 'search.unreachable': { one: '{n} grupo inacessível', other: '{n} grupos inacessíveis' }, + 'search.n_sources': { one: '{n} fonte', other: '{n} fontes' }, 'search.view_files': 'Arquivos', 'search.view_videos': 'Vídeos', 'search.view_music': 'Música', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 489311e..6840774 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -589,6 +589,7 @@ export default { 'search.hint': '输入关键词搜索所有群组。', 'search.indexing': '正在索引 {done} / {total} 个群组…', 'search.unreachable': { other: '{n} 个群组无法连接' }, + 'search.n_sources': { other: '{n} 个来源' }, 'search.view_files': '文件', 'search.view_videos': '视频', 'search.view_music': '音乐', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index 418da89..616f169 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -5,6 +5,7 @@ import { t } from './i18n.js'; import { Icon } from './icon.js'; import { MediaThumb, LazyTile } from './video-app.js'; import { formatTime } from './music-player.js'; +import { SourceTag } from './group-name.js'; // -- Music -------------------------------------------------------------------- // @@ -264,9 +265,7 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen }) <div class="music-card-info"> <div class="music-card-title">${album.album}</div> <div class="music-card-sub">${album.artist}</div> - ${repTrack.groupName && html` - <div class="music-card-group">${repTrack.groupName}</div> - `} + <${SourceTag} entries=${album.tracks} cls="music-card-group" /> </div> </div> `; @@ -525,4 +524,10 @@ function MusicApp({ `; } -export { MusicApp, groupMusicEntries, bumpMusicMetaGeneration }; +// foldKey rides along for the Search page's merge unit keys +// (docs/refactoring-search.md §5.2). An album's *display* strings are the +// first-seen spelling, and which group is seen first is the order its index +// happened to arrive in — so keying a unit on them would let the chosen source +// change between page loads. The folded key is the one grouping actually used, +// and is stable. +export { MusicApp, groupMusicEntries, bumpMusicMetaGeneration, foldKey }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js index 1755392..e6f3482 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app.js @@ -8,6 +8,7 @@ import { } from './file-utils.js'; import { transfers } from './transfers.js'; import { MediaThumb, LazyTile } from './video-app.js'; +import { SourceTag } from './group-name.js'; // ── Photos ─────────────────────────────────────────────────────────────────── // @@ -93,9 +94,7 @@ function AlbumCard({ album, transportRef, gekRef, onOpen }) { <div class="photo-album-sub"> ${year}${year ? ' · ' : ''}${t('photo.n_photos', { n: album.photos.length })} </div> - ${cover.groupName && html` - <div class="photo-card-group">${cover.groupName}</div> - `} + <${SourceTag} entries=${album.photos} cls="photo-card-group" /> </div> </div> `; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 5897acb..608bb81 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -9,8 +9,8 @@ import { } from './hub-client.js'; import { FilesPanel, FilePreview } from './files-app.js'; import { VideoApp, groupVideoEntries } from './video-app.js'; -import { MusicApp } from './music-app.js'; -import { PhotosApp } from './photos-app.js'; +import { MusicApp, groupMusicEntries, foldKey } from './music-app.js'; +import { PhotosApp, groupPhotoAlbums } from './photos-app.js'; import { VideoPlayer } from './video-player.js'; import { transfers } from './transfers.js'; import { mergeUnitEntries } from './source-merge.js'; @@ -233,6 +233,30 @@ function videoUnits(entries) { ]; } +// An album is a unit, and so is a track loose enough to have no artist at all. +// `groupMusicEntries` has already folded the two groups' copies into one album +// object, so the key only has to name it stably — hence `foldKey` over the +// display strings, which are whichever spelling arrived first. +function musicUnits(entries) { + const { tracks, albums } = groupMusicEntries(entries, SEARCH_AUDIO_ROOT); + return [ + ...albums.map((a) => ({ + key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`, + entries: a.tracks, + })), + ...tracks.map((e) => ({ key: `track:${e.id}`, entries: [e] })), + ]; +} + +// A photo album is its directory, which is already prefixed per group when the +// entries are built — so two groups whose roots have different basenames stay +// two albums, and the same photo belongs in both. Only same-named albums +// collapse, which is the reported shape. +function photoUnits(entries) { + return groupPhotoAlbums(entries, SEARCH_PHOTO_ROOTS) + .map((a) => ({ key: `album:${a.dir}`, entries: a.photos })); +} + function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) { const [indexedGroups, setIndexedGroups] = useState(new Map()); const [progress, setProgress] = useState({ done: 0, total: 0, unreachable: [] }); @@ -254,6 +278,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // key, so a group's posters/thumbnails recover the moment it reconnects // (after a pool eviction) instead of staying stuck on a spinner. const connGenRef = useRef(new Map()); + // groupIds whose connection failed — see markGroupDown below. + const downGroups = useRef(new Set()); const mountedRef = useRef(true); const [connectionGen, setConnectionGen] = useState(0); const debounceRef = useRef(null); @@ -301,6 +327,27 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // -- Connection management -- + // A group whose connection failed stops being chosen as a merged entry's + // source, so a unit fails over to another group that has the file + // (docs/refactoring-search.md §5.4). Without this the merge could make a + // file *less* available than it was before it, which would be a regression + // dressed as a feature. + // + // Held in a ref and read live by `mergeOpts.isDown`; what actually rebuilds + // the entry lists is the `connectionGen` bump, which they already depend on. + // Eviction is deliberately not a failure — `_onEvict` only drops the recorded + // connection, and the group is picked again as readily as before. + const markGroupDown = useCallback((groupId, down) => { + let changed; + if (down) { + changed = !downGroups.current.has(groupId); + downGroups.current.add(groupId); + } else { + changed = downGroups.current.delete(groupId); + } + if (changed && mountedRef.current) setConnectionGen((g) => g + 1); + }, []); + const connectGroup = useCallback(async (groupId) => { if (groupConns.current.has(groupId)) { const c = groupConns.current.get(groupId); @@ -308,7 +355,14 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) } if (!poolRef.current) throw new Error('no pool'); const bundleKey = session.bundleKey || await _loadBundleKey(); - const conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId); + let conn; + try { + conn = await poolRef.current.connect(groupId, token, bundleKey, username, userId); + } catch (e) { + markGroupDown(groupId, true); + throw e; + } + markGroupDown(groupId, false); // A concurrent caller for the same group (several tiles mounting at once) // may already have recorded this exact transport while we awaited. Only @@ -339,7 +393,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) // module-wide bumps stay for an operator's TMDB override/rematch only. setConnectionGen((g) => g + 1); return entry; - }, [token, username, userId]); + }, [token, username, userId, markGroupDown]); // Warm up connections as soon as indexing finishes so thumbnails start // loading before the user switches views — but only up to the pool's @@ -431,6 +485,11 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) const data = indexedGroups.get(gid); return !!(data && data.isNodeAdmin); }, + // Read live off the ref rather than captured: what rebuilds the lists is + // the `connectionGen` bump markGroupDown fires, and they already depend on + // it. Putting the set in this memo's own dependencies would only add a + // second reason to rebuild the same thing. + isDown: (gid) => downGroups.current.has(gid), }), [indexedGroups, userId]); // Videos view: pre-filtered by videoRoot, path-prefixed, then merged so a @@ -460,7 +519,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) return mergeUnitEntries(videoUnits(result), mergeOpts); }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); - // Music view: pre-filtered by audioRoot, path-prefixed + // Music view: pre-filtered by audioRoot, path-prefixed, then merged per album const musicEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { @@ -483,10 +542,10 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) }); } } - return result; - }, [indexedGroups, q, matchesQuery, connectionGen]); + return mergeUnitEntries(musicUnits(result), mergeOpts); + }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); - // Photos view: pre-filtered by photoRoots, path-prefixed + // Photos view: pre-filtered by photoRoots, path-prefixed, then merged per album const photoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { @@ -510,8 +569,8 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) }); } } - return result; - }, [indexedGroups, q, matchesQuery, connectionGen]); + return mergeUnitEntries(photoUnits(result), mergeOpts); + }, [indexedGroups, q, matchesQuery, connectionGen, mergeOpts]); // -- Callbacks -- diff --git a/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js b/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js index ccc4cfd..2c08e06 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/source-merge.js @@ -136,24 +136,41 @@ function mergeUnitEntries(units, opts) { } /** - * What the group badge should say for one entry. + * What the group badge should say — for one entry, or for a whole unit. + * + * Takes a list, because a card stands for a unit while the entry it is drawn + * from is one file. A show's poster entry is picked for its *thumbnail* + * (`episodes.find((e) => e.thumb_hash)`), so reading the badge off it would + * report that one episode's sources: a show in two groups whose cover episode + * sits in only one of them would claim a single source. The union over the + * unit is the number the reader is actually being told. * * Returns `{ count, name, groupId }` rather than a rendered string: this module * is executed standalone under node by its test, so it holds no reference to * `i18n.js`. A caller renders `name` (a link to `groupId`) when `count` is 1, - * and a plural of `count` otherwise — which group was picked is deliberately + * and a plural of `count` above that — which group was picked is deliberately * never shown. * - * An entry with no `_sources` at all is the single-group Group page, where - * there is one source by construction and no badge is drawn. + * `count: 0` is an entry with no group at all, which is the single-group Group + * page: there is one source by construction and no badge is drawn there. */ -function sourceLabel(entry) { - const sources = (entry && entry._sources) || []; - if (sources.length > 1) return { count: sources.length, name: '', groupId: null }; +function sourceLabel(entries) { + const list = Array.isArray(entries) ? entries : [entries]; + const groups = new Map(); + for (const e of list) { + if (!e) continue; + // An un-merged entry carries no `_sources`; it is its own single source. + const sources = (e._sources && e._sources.length) + ? e._sources + : (e.groupId ? [_source(e)] : []); + for (const s of sources) if (!groups.has(s.groupId)) groups.set(s.groupId, s); + } + if (groups.size > 1) return { count: groups.size, name: '', groupId: null }; + const only = groups.values().next().value; return { - count: 1, - name: (entry && entry.groupName) || '', - groupId: (entry && entry.groupId) || null, + count: groups.size, + name: (only && only.groupName) || '', + groupId: (only && only.groupId) || null, }; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 19b0f0f..4f52b16 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -4,6 +4,7 @@ import { import { t } from './i18n.js'; import { Icon } from './icon.js'; import { formatSize, pipelinedDownload } from './file-utils.js'; +import { SourceTag } from './group-name.js'; // ── Videos ─────────────────────────────────────────────────────────────────── // @@ -319,6 +320,7 @@ function useSeasonMeta(transportRef, tmdbId, season, active) { function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved, onNeedConn, + sourceEntries, }) { const tRef = repEntry._tRef || transportRef; const gRef = repEntry._gRef || gekRef; @@ -402,9 +404,7 @@ function PosterCard({ ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''} ${subtitle ? ` · ${subtitle}` : ''} </div> - ${repEntry.groupName && html` - <div class="video-card-group">${repEntry.groupName}</div> - `} + <${SourceTag} entries=${sourceEntries || repEntry} cls="video-card-group" /> </div> `} </div> @@ -892,6 +892,7 @@ function PosterGrid({ <${PosterCard} title=${e.display_title || e.name} subtitle=${formatDuration(e.duration)} repEntry=${e} groupKey=${`movie:${e.id}`} + sourceEntries=${e} transportRef=${transportRef} gekRef=${gekRef} onNeedConn=${onNeedConn} onOpen=${() => (tmdbEnabled @@ -925,6 +926,7 @@ function PosterGrid({ <${PosterCard} title=${s.title} subtitle=${subtitle} repEntry=${repEntry} + sourceEntries=${s.episodes} groupKey=${s.title} onMetaResolved=${handleMetaResolved} onNeedConn=${onNeedConn} @@ -983,10 +985,7 @@ function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext, o ${' · '}${formatSize(entry.size)} </div> </div> - ${entry.groupName && html` - <a href="#/group/${entry.groupId}" class="badge search-group-badge" - onClick=${(e) => e.stopPropagation()}>${entry.groupName}</a> - `} + <${SourceTag} entries=${entry} cls="badge search-group-badge" link=${true} /> </div> `; } |