added quicksearch feature
This commit is contained in:
@@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
}[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
|
||||
|
||||
Reference in New Issue
Block a user