added quicksearch feature
This commit is contained in:
@@ -46,6 +46,274 @@ data:
|
|||||||
port: 8080
|
port: 8080
|
||||||
assets-path: /app/config/assets
|
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:
|
branding:
|
||||||
logo-url: https://web.dooplex.hu/static/dooplex_logo_orsi_3.png
|
logo-url: https://web.dooplex.hu/static/dooplex_logo_orsi_3.png
|
||||||
favicon-url: https://web.dooplex.hu/static/dooplex_favicon_orsi.png
|
favicon-url: https://web.dooplex.hu/static/dooplex_favicon_orsi.png
|
||||||
@@ -920,6 +1188,53 @@ spec:
|
|||||||
runAsUser: 1000
|
runAsUser: 1000
|
||||||
runAsGroup: 1000
|
runAsGroup: 1000
|
||||||
fsGroup: 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:
|
containers:
|
||||||
- name: glance
|
- name: glance
|
||||||
image: glanceapp/glance:v0.8.4
|
image: glanceapp/glance:v0.8.4
|
||||||
|
|||||||
Reference in New Issue
Block a user