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);