Add full-text search, EU channel theme, and web-only sources
Search: new FTS5-backed SearchStories query, /search JSON endpoint, client-side overlay (search.js) wired into the layout header. EU channel gets its own theme color (#003399) across bg/text/border/glow classes. Sources can now route to non-Matrix channels without validation error (web-only mode); a warning still flags typos.
This commit is contained in:
@@ -58,27 +58,32 @@ html[data-phase="night"] {
|
||||
.bg-theme-gaming { background-color: #4caf7d; }
|
||||
.bg-theme-tech { background-color: #5aa9e6; }
|
||||
.bg-theme-politics { background-color: #e07a5f; }
|
||||
.bg-theme-eu { background-color: #003399; }
|
||||
.bg-theme-music { background-color: #b079d6; }
|
||||
|
||||
.text-theme-gaming { color: #2d8a5a; }
|
||||
.text-theme-tech { color: #2f7fb8; }
|
||||
.text-theme-politics { color: #b8523a; }
|
||||
.text-theme-eu { color: #003399; }
|
||||
.text-theme-music { color: #8a4fb8; }
|
||||
|
||||
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
|
||||
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
|
||||
.decoration-theme-politics { text-decoration-color: #e07a5f; }
|
||||
.decoration-theme-eu { text-decoration-color: #003399; }
|
||||
.decoration-theme-music { text-decoration-color: #b079d6; }
|
||||
|
||||
.border-theme-gaming { border-color: #4caf7d; }
|
||||
.border-theme-tech { border-color: #5aa9e6; }
|
||||
.border-theme-politics { border-color: #e07a5f; }
|
||||
.border-theme-eu { border-color: #003399; }
|
||||
.border-theme-music { border-color: #b079d6; }
|
||||
|
||||
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
|
||||
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-tech { box-shadow: 0 0 0 2px rgba(90,169,230,0.35), 0 0 24px 4px rgba(90,169,230,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-politics { box-shadow: 0 0 0 2px rgba(224,122,95,0.35), 0 0 24px 4px rgba(224,122,95,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-eu { box-shadow: 0 0 0 2px rgba(0,51,153,0.35), 0 0 24px 4px rgba(0,51,153,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
.glow-theme-music { box-shadow: 0 0 0 2px rgba(176,121,214,0.35), 0 0 24px 4px rgba(176,121,214,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
|
||||
|
||||
@keyframes pete-glow-pulse {
|
||||
|
||||
177
internal/web/static/js/search.js
Normal file
177
internal/web/static/js/search.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// Cmd+K / Ctrl+K command palette. Hits /api/search and renders editorial-card
|
||||
// results. Vanilla JS, no deps; styles come from output.css (Tailwind).
|
||||
(function () {
|
||||
const overlay = document.getElementById("pete-search");
|
||||
if (!overlay) return;
|
||||
const input = overlay.querySelector("[data-search-input]");
|
||||
const list = overlay.querySelector("[data-search-list]");
|
||||
const meta = overlay.querySelector("[data-search-meta]");
|
||||
|
||||
let activeIndex = -1;
|
||||
let items = [];
|
||||
let inflight = null;
|
||||
let debounceTimer = 0;
|
||||
let lastQuery = "";
|
||||
|
||||
function open() {
|
||||
if (!overlay.classList.contains("hidden")) return;
|
||||
overlay.classList.remove("hidden");
|
||||
document.body.classList.add("overflow-hidden");
|
||||
requestAnimationFrame(() => input.focus());
|
||||
}
|
||||
|
||||
function close() {
|
||||
overlay.classList.add("hidden");
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
input.value = "";
|
||||
list.innerHTML = "";
|
||||
meta.textContent = "";
|
||||
items = [];
|
||||
activeIndex = -1;
|
||||
lastQuery = "";
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function render(results) {
|
||||
items = results || [];
|
||||
activeIndex = items.length > 0 ? 0 : -1;
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const html = items.map((r, i) => {
|
||||
const thumb = r.thumb_url
|
||||
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-[color:var(--ink)]/5">
|
||||
<img src="${escapeHTML(r.thumb_url)}" alt="" loading="lazy" decoding="async"
|
||||
class="h-full w-full object-cover">
|
||||
</div>`
|
||||
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;
|
||||
const postedRing = r.posted ? ` border-theme-${escapeHTML(r.channel_theme)}` : "";
|
||||
return `
|
||||
<a href="${escapeHTML(r.article_url)}" target="_blank" rel="noopener noreferrer"
|
||||
data-idx="${i}"
|
||||
class="search-result group flex items-start gap-4 rounded-2xl p-3 transition border-2 border-transparent hover:bg-[color:var(--ink)]/5${postedRing}">
|
||||
${thumb}
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex items-center gap-2 text-xs flex-wrap">
|
||||
<span class="inline-flex items-center rounded-full bg-theme-${escapeHTML(r.channel_theme)} text-white px-2.5 py-0.5 font-semibold uppercase tracking-wider">
|
||||
<span aria-hidden="true" class="mr-1">${escapeHTML(r.channel_emoji)}</span>${escapeHTML(r.channel_title)}
|
||||
</span>
|
||||
<span class="text-[color:var(--ink)]/60 font-semibold">${escapeHTML(r.source)}</span>
|
||||
<span class="text-[color:var(--ink)]/50">${escapeHTML(r.time_ago)}</span>
|
||||
</div>
|
||||
<h3 class="font-display text-base sm:text-lg font-semibold leading-snug">${escapeHTML(r.headline)}</h3>
|
||||
${r.lede ? `<p class="text-sm text-[color:var(--ink)]/70 line-clamp-2">${escapeHTML(r.lede)}</p>` : ""}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join("");
|
||||
list.innerHTML = html;
|
||||
paintActive();
|
||||
}
|
||||
|
||||
function paintActive() {
|
||||
const nodes = list.querySelectorAll(".search-result");
|
||||
nodes.forEach((n, i) => {
|
||||
if (i === activeIndex) {
|
||||
n.classList.add("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15");
|
||||
n.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
n.classList.remove("bg-[color:var(--ink)]/5", "border-[color:var(--ink)]/15");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runQuery(q) {
|
||||
if (q === lastQuery) return;
|
||||
lastQuery = q;
|
||||
if (!q) {
|
||||
render([]);
|
||||
meta.textContent = "type to search · esc to close";
|
||||
return;
|
||||
}
|
||||
if (inflight) inflight.abort();
|
||||
const ctrl = new AbortController();
|
||||
inflight = ctrl;
|
||||
meta.textContent = "searching…";
|
||||
try {
|
||||
const res = await fetch("/api/search?q=" + encodeURIComponent(q), { signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error("search failed: " + res.status);
|
||||
const data = await res.json();
|
||||
if (ctrl !== inflight) return;
|
||||
render(data.results || []);
|
||||
const n = (data.results || []).length;
|
||||
meta.textContent = n === 0 ? "no matches" : `${n} result${n === 1 ? "" : "s"}`;
|
||||
} catch (err) {
|
||||
if (err.name === "AbortError") return;
|
||||
console.error(err);
|
||||
meta.textContent = "search error";
|
||||
} finally {
|
||||
if (ctrl === inflight) inflight = null;
|
||||
}
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer);
|
||||
const q = input.value.trim();
|
||||
debounceTimer = window.setTimeout(() => runQuery(q), 120);
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (items.length === 0) return;
|
||||
activeIndex = (activeIndex + 1) % items.length;
|
||||
paintActive();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (items.length === 0) return;
|
||||
activeIndex = (activeIndex - 1 + items.length) % items.length;
|
||||
paintActive();
|
||||
} else if (e.key === "Enter") {
|
||||
if (activeIndex >= 0 && items[activeIndex]) {
|
||||
e.preventDefault();
|
||||
window.open(items[activeIndex].article_url, "_blank", "noopener");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const isK = e.key === "k" || e.key === "K";
|
||||
if ((e.metaKey || e.ctrlKey) && isK) {
|
||||
e.preventDefault();
|
||||
if (overlay.classList.contains("hidden")) open();
|
||||
else close();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape" && !overlay.classList.contains("hidden")) {
|
||||
close();
|
||||
}
|
||||
if (e.key === "/" && overlay.classList.contains("hidden")) {
|
||||
const tag = (document.activeElement && document.activeElement.tagName) || "";
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
e.preventDefault();
|
||||
open();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-search-trigger]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
open();
|
||||
});
|
||||
});
|
||||
|
||||
meta.textContent = "type to search · esc to close";
|
||||
})();
|
||||
Reference in New Issue
Block a user