added quicksearch feature

This commit is contained in:
2026-01-22 21:13:49 +01:00
parent d8be2ab947
commit 8b2801cbd9
+315
View File
@@ -46,6 +46,274 @@ data:
port: 8080
assets-path: /app/config/assets
document:
head: |
<style>
/* Glance Quick Launch overlay */
#gql-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.35);
display: none;
align-items: flex-start;
justify-content: center;
padding-top: 10vh;
z-index: 99999;
}
#gql-box {
width: min(720px, 92vw);
border-radius: 14px;
background: rgba(20, 22, 30, .92);
box-shadow: 0 10px 30px rgba(0,0,0,.35);
overflow: hidden;
backdrop-filter: blur(8px);
}
#gql-input {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
border: 0;
outline: none;
font-size: 16px;
color: #fff;
background: rgba(0,0,0,.25);
}
#gql-hint {
padding: 8px 16px;
font-size: 12px;
opacity: .75;
color: #fff;
}
#gql-list {
max-height: 360px;
overflow: auto;
padding: 6px;
}
.gql-item {
padding: 10px 10px;
border-radius: 10px;
cursor: pointer;
color: #fff;
display: flex;
gap: 10px;
align-items: baseline;
}
.gql-item:hover, .gql-item.active { background: rgba(255,255,255,.10); }
.gql-title { font-weight: 600; }
.gql-url { font-size: 12px; opacity: .7; word-break: break-all; }
</style>
<script>
(() => {
const MAX_RESULTS = 12;
const overlay = document.createElement('div');
overlay.id = 'gql-overlay';
overlay.innerHTML = `
<div id="gql-box" role="dialog" aria-modal="true">
<input id="gql-input" type="text" autocomplete="off" spellcheck="false" placeholder="Type to search bookmarks…"/>
<div id="gql-hint">↑/↓ to navigate • Enter to open • Esc to close</div>
<div id="gql-list"></div>
</div>
`;
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(overlay));
const $ = (sel) => overlay.querySelector(sel);
const input = () => $('#gql-input');
const list = () => $('#gql-list');
let indexed = [];
let activeIndex = 0;
let lastQuery = '';
const BOOKMARKS_INDEX_URL = '/assets/bookmarks.json';
let indexLoaded = false;
let indexLoading = null;
function indexLinksFromDom() {
const anchors = document.querySelectorAll('.widget.widget-type-bookmarks a.bookmarks-link[href]');
indexed = Array.from(anchors).map(a => ({
title: (a.textContent || '').trim(),
url: a.href,
meta: ''
}));
}
function loadBookmarksIndex() {
if (indexLoaded) return Promise.resolve();
if (indexLoading) return indexLoading;
indexLoading = fetch(BOOKMARKS_INDEX_URL, { cache: 'no-store' })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
indexed = data.map(x => ({
title: String(x.title ?? x.url ?? ''),
url: String(x.url ?? ''),
meta: [x.page, x.widget, x.group].filter(Boolean).map(v => String(v)).join(' • ')
}));
indexLoaded = true;
})
.catch(e => {
console.warn('Could not load bookmarks index, falling back to DOM only:', e);
indexLinksFromDom();
indexLoaded = true;
});
return indexLoading;
}
// Load ASAP (works even if DOMContentLoaded already happened)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => loadBookmarksIndex());
} else {
loadBookmarksIndex();
}
function normalize(s) {
return String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
}
function score(item, q) {
const t = normalize(item.title);
if (!q) return 0;
if (t === q) return 200;
if (t.startsWith(q)) return 120;
// boost if query matches start of any word
const words = t.split(' ');
if (words.some(w => w.startsWith(q))) return 95;
if (t.includes(q)) return 70;
return -1;
}
function render(q) {
const results = indexed
.map(it => ({ ...it, s: score(it, q) }))
.filter(it => it.s >= 0)
.sort((a,b) => b.s - a.s)
.slice(0, MAX_RESULTS);
activeIndex = 0;
list().innerHTML = results.map((r, i) => `
<div class="gql-item ${i===0 ? 'active' : ''}" data-i="${i}">
<div class="gql-title">${escapeHtml(r.title || r.url)}</div>
<div class="gql-url">${escapeHtml(r.meta || r.url)}</div>
</div>
`).join('');
list().onclick = (e) => {
const item = e.target.closest('.gql-item');
if (!item) return;
const i = Number(item.dataset.i);
openResult(results, i, false);
};
return results;
}
function setActive(i) {
const items = Array.from(list().querySelectorAll('.gql-item'));
items.forEach(el => el.classList.remove('active'));
if (items[i]) {
items[i].classList.add('active');
items[i].scrollIntoView({ block: 'nearest' });
}
}
function openOverlay(withInitialText = '') {
overlay.style.display = 'flex';
input().value = withInitialText;
lastQuery = withInitialText;
// show something instantly
list().innerHTML = `<div class="gql-item active"><div class="gql-title">Loading…</div></div>`;
// then render once index is available
loadBookmarksIndex().then(() => {
render(normalize(input().value));
input().focus();
});
function onInput() {
lastQuery = input().value;
render(normalize(lastQuery));
}
input().oninput = onInput;
input().onkeydown = (e) => {
const items = list().querySelectorAll('.gql-item');
const count = items.length;
if (e.key === 'Escape') { e.preventDefault(); closeOverlay(); return; }
if (e.key === 'ArrowDown' && count) { e.preventDefault(); activeIndex = Math.min(activeIndex+1, count-1); setActive(activeIndex); return; }
if (e.key === 'ArrowUp' && count) { e.preventDefault(); activeIndex = Math.max(activeIndex-1, 0); setActive(activeIndex); return; }
if (e.key === 'Enter') {
e.preventDefault();
const q = normalize(input().value);
const resultsNow = indexed
.map(it => ({ ...it, s: score(it, q) }))
.filter(it => it.s >= 0)
.sort((a,b) => b.s - a.s)
.slice(0, MAX_RESULTS);
openResult(resultsNow, activeIndex);
}
};
overlay.onclick = (e) => { if (e.target === overlay) closeOverlay(); };
}
function openResult(results, i) {
const r = results[i];
if (!r) return;
closeOverlay();
window.open(r.url, '_blank', 'noopener,noreferrer');
}
function closeOverlay() {
overlay.style.display = 'none';
list().innerHTML = '';
activeIndex = 0;
}
function escapeHtml(str) {
return (str || '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
// Global key handler: "just start typing"
window.addEventListener('keydown', (e) => {
if (overlay.style.display === 'flex') return;
// ignore when typing in inputs/textareas or using modifier shortcuts
const tag = (document.activeElement && document.activeElement.tagName || '').toLowerCase();
const typingIntoField = tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable;
if (typingIntoField) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
// Printable character opens overlay with that character
if (e.key.length === 1 && !e.repeat) {
openOverlay(e.key);
e.preventDefault();
return;
}
// Optional: slash to open empty
if (e.key === '/') {
openOverlay('');
e.preventDefault();
}
});
})();
</script>
branding:
logo-url: https://web.dooplex.hu/static/dooplex_logo_orsi_3.png
favicon-url: https://web.dooplex.hu/static/dooplex_favicon_orsi.png
@@ -920,6 +1188,53 @@ spec:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
initContainers:
- name: build-bookmarks-index
image: mikefarah/yq:4.50.1
securityContext:
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: false
command: ["/bin/sh", "-c"]
args:
- |
set -eux
which yq
yq --version
mkdir -p /app/assets
yq eval -o=json '
[ .pages[] as $p
| $p.columns[]? as $c
| $c.widgets[]? as $w
| select($w.type == "bookmarks")
| $w.groups[]? as $g
| $g.links[]?
| select(.url != null and .url != "")
| {
"title": (.title // .url),
"url": .url,
"page": ($p.name // ""),
"widget": ($w.title // ""),
"group": ($g.title // "")
}
] | unique_by(.url)
' /config/glance.yml > /app/assets/bookmarks.json
echo "Bookmarks indexed: $(yq eval -r 'length' /app/assets/bookmarks.json)"
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: assets
mountPath: /app/assets
volumeMounts:
- name: config
mountPath: /config
readOnly: true
- name: assets
mountPath: /app/assets
containers:
- name: glance
image: glanceapp/glance:v0.8.4