From f2cebddd4d6497a3bf3f8215c7763dc5488a80c8 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 25 May 2026 10:08:32 +0200 Subject: [PATCH] update search to include video titles --- public/s/css/f0ckm.css | 29 ++++++ public/s/js/f0ckm.js | 158 ++++++++++++++++++++++++++------- src/inc/routes/apiv2/index.mjs | 22 +++++ src/inc/routes/search.mjs | 42 +++++++++ 4 files changed, 218 insertions(+), 33 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index a15f266..4a3da36 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -5423,6 +5423,35 @@ input { white-space: nowrap; } +/* Section headers inside the suggestion dropdown (e.g. "Tags" / "Titles") */ +.tag-suggestion-header { + padding: 5px 12px 3px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + user-select: none; +} + +/* Icon prefix for title-type suggestion items */ +.tag-suggestion-icon { + flex-shrink: 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-right: 8px; +} + +.tag-suggestion-item--title .tag-suggestion-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--white, #e0e0e0); +} + + @media (max-width: 555px) { .tag-suggestions { position: fixed; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 063edcd..5d0abbc 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -4269,44 +4269,102 @@ window.cancelAnimFrame = (function () { } }; - const renderSuggestions = (items) => { + const renderSuggestions = (tagItems, titleItems) => { suggestions.innerHTML = ''; highlightIdx = -1; - if (!items.length) { + const totalItems = (tagItems || []).length + (titleItems || []).length; + if (!totalItems) { suggestions.style.display = 'none'; return; } - items.forEach(s => { - const div = document.createElement('div'); - div.className = 'tag-suggestion-item'; - const name = document.createElement('span'); - name.className = 'tag-suggestion-name'; - name.textContent = s.tag; + const addSectionHeader = (label) => { + const hdr = document.createElement('div'); + hdr.className = 'tag-suggestion-header'; + hdr.textContent = label; + suggestions.appendChild(hdr); + }; - const meta = document.createElement('span'); - meta.className = 'tag-suggestion-meta'; - meta.textContent = `${s.tagged}× · ${s.score.toFixed(2)}`; + // --- Tag results --- + if (tagItems && tagItems.length) { + if (titleItems && titleItems.length) addSectionHeader('Tags'); + tagItems.forEach(s => { + const div = document.createElement('div'); + div.className = 'tag-suggestion-item'; + div.dataset.type = 'tag'; + div.dataset.value = s.tag; - div.appendChild(name); - div.appendChild(meta); + const name = document.createElement('span'); + name.className = 'tag-suggestion-name'; + name.textContent = s.tag; - div.addEventListener('mousedown', (e) => { - e.preventDefault(); - const isStrict = strict && strict.checked; - if (isStrict) { - const parts = input.value.split(','); - parts[parts.length - 1] = s.tag; - input.value = parts.join(',') + ','; - } else { - input.value = s.tag; - } - suggestions.style.display = 'none'; - highlightIdx = -1; - input.focus(); + const meta = document.createElement('span'); + meta.className = 'tag-suggestion-meta'; + meta.textContent = `${s.tagged}× · ${s.score.toFixed(2)}`; + + div.appendChild(name); + div.appendChild(meta); + + div.addEventListener('mousedown', (e) => { + e.preventDefault(); + const isStrict = strict && strict.checked; + if (isStrict) { + const parts = input.value.split(','); + parts[parts.length - 1] = s.tag; + input.value = parts.join(',') + ','; + } else { + input.value = s.tag; + } + suggestions.style.display = 'none'; + highlightIdx = -1; + input.focus(); + }); + suggestions.appendChild(div); }); - suggestions.appendChild(div); - }); + } + + // --- Title results --- + if (titleItems && titleItems.length) { + if (tagItems && tagItems.length) addSectionHeader('Titles'); + titleItems.forEach(s => { + const div = document.createElement('div'); + div.className = 'tag-suggestion-item tag-suggestion-item--title'; + div.dataset.type = 'title'; + div.dataset.id = s.id; + div.dataset.title = s.title; + + const icon = document.createElement('span'); + icon.className = 'tag-suggestion-icon'; + icon.innerHTML = ''; + + const name = document.createElement('span'); + name.className = 'tag-suggestion-name'; + name.textContent = s.title.length > 60 ? s.title.substring(0, 60) + '…' : s.title; + + const meta = document.createElement('span'); + meta.className = 'tag-suggestion-meta'; + meta.textContent = `#${s.id}`; + + div.appendChild(icon); + div.appendChild(name); + div.appendChild(meta); + + div.addEventListener('mousedown', (e) => { + e.preventDefault(); + suggestions.style.display = 'none'; + highlightIdx = -1; + toggleSearch(false); + const target = `/search/?tag=title:${encodeURIComponent(s.title)}`; + if (typeof loadPageAjax === 'function') { + loadPageAjax(target, true); + } else { + window.location.href = target; + } + }); + suggestions.appendChild(div); + }); + } + suggestions.style.display = 'block'; }; @@ -4321,10 +4379,14 @@ window.cancelAnimFrame = (function () { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { try { - const res = await fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(q)}`); - const json = await res.json(); - if (json.success && json.suggestions) { - renderSuggestions(json.suggestions.slice(0, 8)); + const [tagRes, titleRes] = await Promise.all([ + fetch(`/api/v2/tags/suggest?q=${encodeURIComponent(q)}`).then(r => r.json()).catch(() => ({ success: false })), + fetch(`/api/v2/items/suggest?q=${encodeURIComponent(q)}`).then(r => r.json()).catch(() => ({ success: false })) + ]); + const tagSuggestions = (tagRes.success && tagRes.suggestions) ? tagRes.suggestions.slice(0, 6) : []; + const titleSuggestions = (titleRes.success && titleRes.suggestions) ? titleRes.suggestions.slice(0, 4) : []; + if (tagSuggestions.length || titleSuggestions.length) { + renderSuggestions(tagSuggestions, titleSuggestions); } else { suggestions.style.display = 'none'; } @@ -4467,7 +4529,22 @@ window.cancelAnimFrame = (function () { if (highlightIdx >= 0) { const items = suggestions.querySelectorAll('.tag-suggestion-item'); if (items[highlightIdx]) { - const selectedTag = items[highlightIdx].querySelector('.tag-suggestion-name').textContent; + const el = items[highlightIdx]; + // Title results: go to the search page showing all items with this title + if (el.dataset.type === 'title') { + suggestions.style.display = 'none'; + highlightIdx = -1; + toggleSearch(false); + const target = `/search/?tag=title:${encodeURIComponent(el.dataset.title)}`; + if (typeof loadPageAjax === 'function') { + loadPageAjax(target, true); + } else { + window.location.href = target; + } + return; + } + // Tag results: fill input + const selectedTag = el.querySelector('.tag-suggestion-name').textContent; const isStrict = strict && strict.checked; if (isStrict) { const parts = input.value.split(','); @@ -4481,7 +4558,22 @@ window.cancelAnimFrame = (function () { return; } } + // Snapshot before hiding — if there are only title matches and no tag matches, + // go to the title search page instead of a futile tag search. + const tagItems = suggestions.querySelectorAll('.tag-suggestion-item[data-type="tag"]'); + const titleItems = suggestions.querySelectorAll('.tag-suggestion-item[data-type="title"]'); suggestions.style.display = 'none'; + if (tagItems.length === 0 && titleItems.length > 0) { + toggleSearch(false); + const q = input.value.trim(); + const target = `/search/?tag=title:${encodeURIComponent(q)}`; + if (typeof loadPageAjax === 'function') { + loadPageAjax(target, true); + } else { + window.location.href = target; + } + return; + } doSearch(); } }); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 51e3806..1c0443e 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -767,6 +767,28 @@ export default router => { } }); + group.get(/\/items\/suggest$/, async (req, res) => { + const searchString = req.url.qs.q; + if (!searchString || searchString.length < 1) { + return res.json({ success: false, suggestions: [] }); + } + + try { + const items = await db` + SELECT id, title + FROM items + WHERE title IS NOT NULL + AND active = true + AND title ILIKE ${'%' + searchString + '%'} + ORDER BY id DESC + LIMIT 8 + `; + return res.json({ success: true, suggestions: items }); + } catch (err) { + return res.json({ success: false, error: 'Item title suggestion error', suggestions: [] }); + } + }); + // tags lol group.put(/\/tags\/rename\/(?.*)/, lib.modAuth, async (req, res) => { diff --git a/src/inc/routes/search.mjs b/src/inc/routes/search.mjs index 2d59d8f..44ad32a 100644 --- a/src/inc/routes/search.mjs +++ b/src/inc/routes/search.mjs @@ -56,6 +56,48 @@ export default (router, tpl) => { path: '&page=' }; } + else if (tag.startsWith('title:')) { + const titleQuery = tag.substring(6).trim(); + const q = '%' + titleQuery + '%'; + + total = (await db` + select count(*) as total + from "items" + where title ilike ${q} and active = true + `)[0]?.total ?? 0; + total = +total; + + const pages = +Math.ceil(total / _eps); + const act_page = Math.min(Math.max(pages, 1), page || 1); + const offset = Math.max(0, (act_page - 1) * _eps); + + ret = await db` + select * + from "items" + where title ilike ${q} and active = true + order by id desc + offset ${offset} + limit ${_eps} + `; + + const cheat = []; + for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++) + cheat.push(i); + + pagination = { + start: 1, + end: pages, + prev: (act_page > 1) ? act_page - 1 : null, + next: (act_page < pages) ? act_page + 1 : null, + page: act_page, + cheat: cheat, + uff: false + }; + link = { + main: `/search/?tag=${encodeURIComponent(tag)}`, + path: '&page=' + }; + } else if (mode === 'strict') { const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);