846 lines
35 KiB
HTML
846 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>sFetch Results</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
app: {
|
|
bg: "#f6f8fb",
|
|
surface: "#ffffff",
|
|
ink: "#111827",
|
|
muted: "#5f6b7a",
|
|
border: "#d8dee8",
|
|
soft: "#eef2f7",
|
|
primary: "#174ea6",
|
|
primaryDark: "#123b7d",
|
|
success: "#137333",
|
|
warning: "#b06000",
|
|
},
|
|
},
|
|
boxShadow: {
|
|
panel: "0 18px 45px rgba(15, 23, 42, 0.08)",
|
|
focus: "0 0 0 3px rgba(23, 78, 166, 0.14)",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
<style>
|
|
:root { color-scheme: light; }
|
|
body {
|
|
background: #ffffff;
|
|
color: #111827;
|
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
}
|
|
.skeleton {
|
|
background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 37%, #eef2f7 63%);
|
|
background-size: 400% 100%;
|
|
animation: shimmer 1.4s ease infinite;
|
|
}
|
|
mark {
|
|
background: rgba(23, 78, 166, 0.12);
|
|
color: #111827;
|
|
padding: 0 0.12rem;
|
|
border-radius: 0.2rem;
|
|
}
|
|
@keyframes shimmer {
|
|
0% { background-position: 100% 50%; }
|
|
100% { background-position: 0 50%; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen">
|
|
<header class="sticky top-0 z-20 border-b border-app-border bg-white/95 backdrop-blur">
|
|
<div class="mx-auto flex max-w-6xl flex-col gap-4 px-5 py-4 lg:flex-row lg:items-center">
|
|
<a href="./index.html" class="text-2xl font-semibold tracking-tight text-app-ink">sFetch</a>
|
|
|
|
<form id="searchForm" class="flex flex-1 items-center gap-3">
|
|
<label
|
|
for="searchInput"
|
|
class="flex min-h-12 flex-1 items-center gap-3 rounded-lg border border-app-border bg-white px-4 transition focus-within:border-app-primary focus-within:shadow-focus"
|
|
>
|
|
<svg class="h-5 w-5 shrink-0 text-app-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="6"></circle>
|
|
<path d="M20 20L16.65 16.65"></path>
|
|
</svg>
|
|
<input
|
|
id="searchInput"
|
|
type="text"
|
|
autocomplete="off"
|
|
class="w-full bg-transparent text-base text-app-ink outline-none placeholder:text-app-muted"
|
|
placeholder="Search sFetch"
|
|
/>
|
|
</label>
|
|
<button type="submit" class="rounded-md bg-app-primary px-5 py-3 text-sm font-semibold text-white transition hover:bg-app-primaryDark">
|
|
Search
|
|
</button>
|
|
</form>
|
|
|
|
<nav class="flex flex-wrap gap-2 text-sm">
|
|
<a href="./index.html" class="rounded-md px-3 py-2 font-medium text-app-muted transition hover:bg-app-soft hover:text-app-ink">Search Home</a>
|
|
<a href="./ai.html" class="rounded-md px-3 py-2 font-medium text-app-muted transition hover:bg-app-soft hover:text-app-ink">AI Chat</a>
|
|
</nav>
|
|
</div>
|
|
<nav class="mx-auto flex max-w-6xl gap-7 px-5 text-sm" aria-label="Search verticals">
|
|
<button id="tabAll" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-app-muted">All</button>
|
|
<button id="tabImages" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-app-muted">Images</button>
|
|
<button id="tabVideos" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-app-muted">Videos</button>
|
|
</nav>
|
|
</header>
|
|
|
|
<main class="mx-auto max-w-6xl px-5 py-8">
|
|
<p id="metaText" class="text-sm text-app-muted"></p>
|
|
|
|
<section id="aiPanel" class="mt-5 hidden max-w-4xl rounded-lg border border-app-border bg-app-bg p-5">
|
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<p class="text-sm font-semibold uppercase tracking-wide text-app-primary">AI answer</p>
|
|
<p id="aiStatus" class="mt-1 text-sm text-app-muted">Preparing answer...</p>
|
|
</div>
|
|
<div class="grid gap-3 sm:grid-cols-[220px_150px_auto] sm:items-end">
|
|
<div>
|
|
<label for="aiModelSelect" class="mb-1 block text-xs font-medium text-app-muted">Model</label>
|
|
<select id="aiModelSelect" class="w-full rounded-md border border-app-border bg-white px-3 py-2 text-sm text-app-ink outline-none focus:border-app-primary focus:shadow-focus">
|
|
<option value="">Loading models...</option>
|
|
</select>
|
|
</div>
|
|
<label class="flex items-center gap-2 rounded-md border border-app-border bg-white px-3 py-2 text-sm text-app-ink">
|
|
<input id="aiUseWeb" type="checkbox" checked class="h-4 w-4 rounded border-app-border text-app-primary" />
|
|
Web context
|
|
</label>
|
|
<button id="aiRegenerate" class="rounded-md bg-app-ink px-4 py-2 text-sm font-semibold text-white transition hover:bg-black">
|
|
Generate
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="aiAnswer" class="mt-5 whitespace-pre-wrap text-sm leading-7 text-app-ink"></div>
|
|
<details id="aiThinkingWrap" class="mt-4 hidden rounded-md border border-app-border bg-white p-3">
|
|
<summary class="cursor-pointer text-sm font-medium text-app-muted">Reasoning trace</summary>
|
|
<pre id="aiThinking" class="mt-3 whitespace-pre-wrap text-xs leading-5 text-app-muted"></pre>
|
|
</details>
|
|
<div id="aiSources" class="mt-5 grid gap-2"></div>
|
|
</section>
|
|
|
|
<section id="resultsContainer" class="mt-6"></section>
|
|
<nav id="pagination" class="mt-10 flex items-center justify-start gap-2" aria-label="Pagination"></nav>
|
|
</main>
|
|
|
|
<div id="imageModal" class="fixed inset-0 z-50 hidden bg-slate-950/60">
|
|
<div class="absolute inset-y-0 right-0 w-full max-w-4xl border-l border-app-border bg-white shadow-panel">
|
|
<div class="flex items-center justify-between border-b border-app-border px-6 py-4">
|
|
<h3 id="modalTitle" class="truncate text-base font-medium text-app-ink">Image preview</h3>
|
|
<button id="closeModal" class="flex h-9 w-9 items-center justify-center rounded-md text-app-muted transition hover:bg-app-soft hover:text-app-ink">
|
|
X
|
|
</button>
|
|
</div>
|
|
<div class="h-[calc(100vh-73px)] overflow-y-auto px-6 py-5">
|
|
<div class="overflow-hidden rounded-lg bg-app-soft">
|
|
<img id="modalImage" class="max-h-[62vh] w-full object-contain" alt="Preview" />
|
|
</div>
|
|
<div class="mt-6">
|
|
<h4 class="mb-3 text-sm font-medium text-app-muted">Related images</h4>
|
|
<div id="relatedImages" class="grid grid-cols-2 gap-3 sm:grid-cols-3"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = "http://localhost:8000";
|
|
const RESULTS_PER_PAGE = 10;
|
|
|
|
const searchForm = document.getElementById("searchForm");
|
|
const searchInput = document.getElementById("searchInput");
|
|
const resultsContainer = document.getElementById("resultsContainer");
|
|
const metaText = document.getElementById("metaText");
|
|
const paginationNav = document.getElementById("pagination");
|
|
const tabAll = document.getElementById("tabAll");
|
|
const tabImages = document.getElementById("tabImages");
|
|
const tabVideos = document.getElementById("tabVideos");
|
|
const aiPanel = document.getElementById("aiPanel");
|
|
const aiStatus = document.getElementById("aiStatus");
|
|
const aiAnswer = document.getElementById("aiAnswer");
|
|
const aiSources = document.getElementById("aiSources");
|
|
const aiModelSelect = document.getElementById("aiModelSelect");
|
|
const aiUseWeb = document.getElementById("aiUseWeb");
|
|
const aiRegenerate = document.getElementById("aiRegenerate");
|
|
const aiThinkingWrap = document.getElementById("aiThinkingWrap");
|
|
const aiThinking = document.getElementById("aiThinking");
|
|
const imageModal = document.getElementById("imageModal");
|
|
const closeModalBtn = document.getElementById("closeModal");
|
|
const modalImage = document.getElementById("modalImage");
|
|
const modalTitle = document.getElementById("modalTitle");
|
|
const relatedImagesContainer = document.getElementById("relatedImages");
|
|
|
|
let currentType = "all";
|
|
|
|
function escapeHTML(value) {
|
|
return String(value || "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function getParams() {
|
|
return new URLSearchParams(window.location.search);
|
|
}
|
|
|
|
function getTypeFromUrl() {
|
|
const typeValue = getParams().get("type");
|
|
return ["image", "video", "all"].includes(typeValue) ? typeValue : "all";
|
|
}
|
|
|
|
function getQueryFromUrl() {
|
|
return (getParams().get("q") || "").trim();
|
|
}
|
|
|
|
function getPageFromUrl() {
|
|
const page = Number.parseInt(getParams().get("page") || "1", 10);
|
|
return Number.isNaN(page) || page < 1 ? 1 : page;
|
|
}
|
|
|
|
function updateUrl(query, page) {
|
|
const params = getParams();
|
|
params.set("q", query);
|
|
page > 1 ? params.set("page", String(page)) : params.delete("page");
|
|
currentType === "all" ? params.delete("type") : params.set("type", currentType);
|
|
if (currentType === "all") {
|
|
params.set("ai", "1");
|
|
if (aiModelSelect.value) {
|
|
params.set("model", aiModelSelect.value);
|
|
}
|
|
}
|
|
window.history.replaceState({}, "", `${window.location.pathname}?${params.toString()}`);
|
|
}
|
|
|
|
function updateTabsUI() {
|
|
const tabs = [
|
|
[tabAll, currentType === "all"],
|
|
[tabImages, currentType === "image"],
|
|
[tabVideos, currentType === "video"],
|
|
];
|
|
tabs.forEach(([tab, active]) => {
|
|
tab.classList.toggle("border-app-primary", active);
|
|
tab.classList.toggle("text-app-ink", active);
|
|
tab.classList.toggle("border-transparent", !active);
|
|
tab.classList.toggle("text-app-muted", !active);
|
|
});
|
|
}
|
|
|
|
async function loadModels() {
|
|
try {
|
|
const response = await fetch(`${API_BASE}/ai/models`);
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(payload.detail || "Unable to load models.");
|
|
}
|
|
const selectedFromUrl = getParams().get("model");
|
|
aiModelSelect.innerHTML = "";
|
|
(payload.models || []).forEach((model) => {
|
|
const name = model.name || model.model;
|
|
if (!name) {
|
|
return;
|
|
}
|
|
const option = document.createElement("option");
|
|
option.value = name;
|
|
option.textContent = name;
|
|
if (name === selectedFromUrl || (!selectedFromUrl && name === payload.default_model)) {
|
|
option.selected = true;
|
|
}
|
|
aiModelSelect.appendChild(option);
|
|
});
|
|
if (!aiModelSelect.options.length) {
|
|
aiModelSelect.innerHTML = `<option value="${payload.default_model || "gpt-oss:120b"}">${payload.default_model || "gpt-oss:120b"}</option>`;
|
|
}
|
|
} catch {
|
|
aiModelSelect.innerHTML = '<option value="gpt-oss:120b">gpt-oss:120b</option>';
|
|
}
|
|
}
|
|
|
|
async function fetchSearch(type, query, limit, offset) {
|
|
const response = await fetch(
|
|
`${API_BASE}/search?q=${encodeURIComponent(query)}&type=${type}&limit=${limit}&offset=${offset}`
|
|
);
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
throw new Error(data.detail || "Search request failed.");
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function streamSSE(url, payload, handlers) {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!response.ok || !response.body) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.detail || "Model stream failed.");
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const events = buffer.split("\n\n");
|
|
buffer = events.pop() || "";
|
|
for (const rawEvent of events) {
|
|
const eventName = (rawEvent.match(/^event: (.+)$/m) || [])[1] || "message";
|
|
const dataLine = rawEvent
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("data: "))
|
|
.map((line) => line.slice(6))
|
|
.join("\n");
|
|
const data = dataLine ? JSON.parse(dataLine) : {};
|
|
handlers[eventName]?.(data);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchAIAnswer(query) {
|
|
aiPanel.classList.remove("hidden");
|
|
aiStatus.textContent = "Streaming answer with Ollama Cloud...";
|
|
aiAnswer.textContent = "";
|
|
aiSources.innerHTML = "";
|
|
aiThinkingWrap.classList.add("hidden");
|
|
aiThinking.textContent = "";
|
|
aiRegenerate.disabled = true;
|
|
|
|
try {
|
|
let finalContent = "";
|
|
await streamSSE(
|
|
`${API_BASE}/ai/search/stream`,
|
|
{
|
|
query,
|
|
model: aiModelSelect.value,
|
|
include_web: aiUseWeb.checked,
|
|
local_result_limit: 5,
|
|
web_result_limit: 5,
|
|
},
|
|
{
|
|
meta(data) {
|
|
aiStatus.textContent = `Streaming from ${data.model || aiModelSelect.value}`;
|
|
renderAISources(data.sources || []);
|
|
},
|
|
thinking(data) {
|
|
aiThinkingWrap.classList.remove("hidden");
|
|
aiThinking.textContent += data.delta || "";
|
|
},
|
|
content(data) {
|
|
finalContent += data.delta || "";
|
|
aiAnswer.textContent = finalContent;
|
|
},
|
|
done(data) {
|
|
aiStatus.textContent = `Generated by ${data.model || aiModelSelect.value}`;
|
|
aiAnswer.textContent = data.content || finalContent || "No answer returned.";
|
|
},
|
|
error(data) {
|
|
throw new Error(data.detail || "Unable to generate AI answer.");
|
|
},
|
|
}
|
|
);
|
|
} catch (error) {
|
|
aiStatus.textContent = "AI answer unavailable";
|
|
aiAnswer.textContent = error.message || "Unable to generate AI answer.";
|
|
} finally {
|
|
aiRegenerate.disabled = false;
|
|
}
|
|
}
|
|
|
|
function renderAISources(sources) {
|
|
aiSources.innerHTML = "";
|
|
if (!sources.length) {
|
|
return;
|
|
}
|
|
const heading = document.createElement("p");
|
|
heading.className = "text-xs font-semibold uppercase tracking-wide text-app-muted";
|
|
heading.textContent = "Sources";
|
|
aiSources.appendChild(heading);
|
|
sources.slice(0, 8).forEach((source, index) => {
|
|
const link = document.createElement("a");
|
|
link.href = source.url;
|
|
link.target = "_blank";
|
|
link.rel = "noreferrer noopener";
|
|
link.className = "block rounded-md border border-app-border bg-white p-3 text-sm transition hover:border-app-primary";
|
|
link.innerHTML = `
|
|
<span class="font-semibold text-app-ink">[${index + 1}] ${escapeHTML(source.title)}</span>
|
|
<span class="ml-2 rounded bg-app-soft px-2 py-0.5 text-xs uppercase text-app-muted">${escapeHTML(source.source_type)}</span>
|
|
<span class="mt-1 block truncate text-xs text-app-muted">${escapeHTML(source.url)}</span>
|
|
`;
|
|
aiSources.appendChild(link);
|
|
});
|
|
}
|
|
|
|
function extractHost(url) {
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, "");
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
function getYouTubeId(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname.includes("youtube.com")) {
|
|
if (parsed.pathname.startsWith("/watch")) {
|
|
return parsed.searchParams.get("v");
|
|
}
|
|
if (parsed.pathname.startsWith("/embed/")) {
|
|
return parsed.pathname.split("/embed/")[1] || null;
|
|
}
|
|
}
|
|
if (parsed.hostname.includes("youtu.be")) {
|
|
return parsed.pathname.slice(1) || null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function videoThumbnail(url) {
|
|
const ytId = getYouTubeId(url);
|
|
return ytId ? `https://img.youtube.com/vi/${ytId}/hqdefault.jpg` : null;
|
|
}
|
|
|
|
function renderError(message) {
|
|
metaText.textContent = "Search unavailable";
|
|
aiPanel.classList.add("hidden");
|
|
resultsContainer.className = "mt-6";
|
|
resultsContainer.innerHTML = `
|
|
<section class="max-w-2xl rounded-lg border border-app-border bg-app-bg px-5 py-6">
|
|
<p class="text-lg text-app-ink">Unable to load results.</p>
|
|
<p class="mt-2 text-sm text-app-muted">${escapeHTML(message)}</p>
|
|
</section>
|
|
`;
|
|
paginationNav.innerHTML = "";
|
|
}
|
|
|
|
function renderEmpty(query) {
|
|
metaText.textContent = "About 0 results";
|
|
resultsContainer.className = "mt-6";
|
|
resultsContainer.innerHTML = `
|
|
<section class="max-w-2xl rounded-lg border border-app-border bg-app-bg px-5 py-8">
|
|
<h2 class="text-xl font-semibold text-app-ink">No results found</h2>
|
|
<p class="mt-2 text-sm text-app-muted">No indexed pages matched "${escapeHTML(query)}".</p>
|
|
</section>
|
|
`;
|
|
paginationNav.innerHTML = "";
|
|
}
|
|
|
|
function renderPagination(total, currentPage, query) {
|
|
paginationNav.innerHTML = "";
|
|
const totalPages = Math.ceil(total / RESULTS_PER_PAGE);
|
|
if (totalPages <= 1) {
|
|
paginationNav.style.display = "none";
|
|
return;
|
|
}
|
|
paginationNav.style.display = "flex";
|
|
|
|
const button = (label, page, disabled = false, active = false) => {
|
|
const btn = document.createElement("button");
|
|
btn.textContent = label;
|
|
btn.disabled = disabled;
|
|
btn.className = `flex h-10 min-w-10 items-center justify-center rounded-md border px-3 text-sm transition ${
|
|
active
|
|
? "border-app-primary bg-app-primary text-white"
|
|
: disabled
|
|
? "cursor-not-allowed border-app-border text-app-muted/50"
|
|
: "border-app-border text-app-ink hover:border-app-primary hover:text-app-primary"
|
|
}`;
|
|
if (!disabled && !active) {
|
|
btn.addEventListener("click", () => runSearch(query, page));
|
|
}
|
|
return btn;
|
|
};
|
|
|
|
paginationNav.appendChild(button("<", currentPage - 1, currentPage === 1));
|
|
const maxVisiblePages = 5;
|
|
let start = Math.max(1, currentPage - 2);
|
|
let end = Math.min(totalPages, start + maxVisiblePages - 1);
|
|
if (end - start < maxVisiblePages - 1) {
|
|
start = Math.max(1, end - maxVisiblePages + 1);
|
|
}
|
|
for (let i = start; i <= end; i += 1) {
|
|
paginationNav.appendChild(button(String(i), i, false, i === currentPage));
|
|
}
|
|
paginationNav.appendChild(button(">", currentPage + 1, currentPage === totalPages));
|
|
}
|
|
|
|
function openImageModal(imageResult, imageIndex, relatedPool) {
|
|
modalImage.src = imageResult.url;
|
|
modalImage.alt = imageResult.alt_text || "Image preview";
|
|
modalTitle.textContent = imageResult.alt_text || extractHost(imageResult.page_url);
|
|
relatedImagesContainer.innerHTML = "";
|
|
|
|
relatedPool
|
|
.filter((_, idx) => idx !== imageIndex)
|
|
.slice(0, 8)
|
|
.forEach((item) => {
|
|
const thumb = document.createElement("button");
|
|
thumb.className = "overflow-hidden rounded-md border border-app-border transition hover:border-app-primary";
|
|
thumb.innerHTML = `
|
|
<img src="${escapeHTML(item.url)}" alt="${escapeHTML(item.alt_text || "Related image")}" class="h-24 w-full object-cover" loading="lazy" />
|
|
`;
|
|
thumb.addEventListener("click", () => {
|
|
const realIndex = relatedPool.findIndex((candidate) => candidate.id === item.id);
|
|
openImageModal(item, realIndex, relatedPool);
|
|
});
|
|
relatedImagesContainer.appendChild(thumb);
|
|
});
|
|
|
|
imageModal.classList.remove("hidden");
|
|
}
|
|
|
|
function closeImageModal() {
|
|
imageModal.classList.add("hidden");
|
|
}
|
|
|
|
function renderImageGrid(results) {
|
|
aiPanel.classList.add("hidden");
|
|
resultsContainer.className = "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4";
|
|
resultsContainer.innerHTML = "";
|
|
|
|
results.forEach((result, index) => {
|
|
const card = document.createElement("article");
|
|
card.className = "group cursor-pointer overflow-hidden rounded-lg border border-app-border bg-white transition hover:border-app-primary";
|
|
card.innerHTML = `
|
|
<div class="aspect-square overflow-hidden bg-app-soft">
|
|
<img src="${escapeHTML(result.url)}" alt="${escapeHTML(result.alt_text || "Image result")}" class="h-full w-full object-cover transition duration-200 group-hover:scale-105" loading="lazy" />
|
|
</div>
|
|
<div class="truncate px-3 py-2 text-xs text-app-muted">${escapeHTML(result.alt_text || extractHost(result.page_url))}</div>
|
|
`;
|
|
card.addEventListener("click", () => openImageModal(result, index, results));
|
|
resultsContainer.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderVideoCards(results) {
|
|
aiPanel.classList.add("hidden");
|
|
resultsContainer.className = "mt-6 max-w-3xl space-y-4";
|
|
resultsContainer.innerHTML = "";
|
|
|
|
results.forEach((result) => {
|
|
const thumbnail = videoThumbnail(result.url);
|
|
const card = document.createElement("article");
|
|
card.className = "overflow-hidden rounded-lg border border-app-border bg-white";
|
|
card.innerHTML = `
|
|
<a href="${escapeHTML(result.url)}" target="_blank" rel="noreferrer noopener" class="block md:flex">
|
|
<div class="relative h-44 w-full shrink-0 overflow-hidden bg-app-soft md:w-72">
|
|
${
|
|
thumbnail
|
|
? `<img src="${escapeHTML(thumbnail)}" alt="${escapeHTML(result.title)}" class="h-full w-full object-cover" loading="lazy" />`
|
|
: `<div class="flex h-full items-center justify-center text-app-muted">Video</div>`
|
|
}
|
|
</div>
|
|
<div class="space-y-2 p-5">
|
|
<p class="text-xs uppercase text-app-success">${escapeHTML(extractHost(result.url))}</p>
|
|
<h3 class="text-xl font-medium text-app-primary">${escapeHTML(result.title)}</h3>
|
|
<p class="text-sm text-app-muted">Source: ${escapeHTML(extractHost(result.page_url))}</p>
|
|
</div>
|
|
</a>
|
|
`;
|
|
resultsContainer.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderWebList(results) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "max-w-3xl space-y-7";
|
|
|
|
results.forEach((result) => {
|
|
const article = document.createElement("article");
|
|
const host = extractHost(result.url);
|
|
article.className = "space-y-1";
|
|
article.innerHTML = `
|
|
<div class="flex items-center gap-2 text-sm text-app-muted">
|
|
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded bg-app-soft text-xs font-bold text-app-primary">${escapeHTML(host.slice(0, 1).toUpperCase())}</div>
|
|
<div class="min-w-0">
|
|
<p class="text-app-ink">${escapeHTML(host)}</p>
|
|
<p class="truncate text-xs">${escapeHTML(result.url)}</p>
|
|
</div>
|
|
</div>
|
|
<a href="${escapeHTML(result.url)}" target="_blank" rel="noreferrer noopener" class="block text-xl leading-tight text-app-primary hover:underline">${escapeHTML(result.title)}</a>
|
|
<p class="text-sm leading-6 text-app-muted">${result.snippet}</p>
|
|
`;
|
|
wrapper.appendChild(article);
|
|
});
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
function renderAllMode(webData, imageData, videoData, page) {
|
|
aiPanel.classList.remove("hidden");
|
|
const start = (page - 1) * RESULTS_PER_PAGE + 1;
|
|
const end = Math.min(start + webData.results.length - 1, webData.total);
|
|
if (webData.total === 0 && imageData.total === 0 && videoData.total === 0) {
|
|
renderEmpty(webData.query);
|
|
fetchAIAnswer(webData.query);
|
|
return;
|
|
}
|
|
|
|
metaText.textContent = webData.total > 0
|
|
? `${start}-${end} of about ${webData.total} web results`
|
|
: "No direct web matches, showing media results";
|
|
|
|
resultsContainer.className = "mt-8 space-y-9";
|
|
resultsContainer.innerHTML = "";
|
|
|
|
if (webData.results.length) {
|
|
resultsContainer.appendChild(renderWebList(webData.results));
|
|
}
|
|
|
|
if (imageData.results.length) {
|
|
const imageSection = document.createElement("section");
|
|
imageSection.innerHTML = `
|
|
<div class="mb-3 flex max-w-3xl items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-app-ink">Images</h2>
|
|
<button id="seeAllImagesBtn" class="text-sm font-medium text-app-primary hover:underline">See all</button>
|
|
</div>
|
|
`;
|
|
const grid = document.createElement("div");
|
|
grid.className = "grid max-w-3xl grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6";
|
|
imageData.results.slice(0, 6).forEach((result, index) => {
|
|
const button = document.createElement("button");
|
|
button.className = "overflow-hidden rounded-md border border-app-border bg-app-soft";
|
|
button.innerHTML = `<img src="${escapeHTML(result.url)}" alt="${escapeHTML(result.alt_text || "Image result")}" class="aspect-square w-full object-cover" loading="lazy" />`;
|
|
button.addEventListener("click", () => openImageModal(result, index, imageData.results));
|
|
grid.appendChild(button);
|
|
});
|
|
imageSection.appendChild(grid);
|
|
resultsContainer.appendChild(imageSection);
|
|
imageSection.querySelector("#seeAllImagesBtn").addEventListener("click", () => {
|
|
currentType = "image";
|
|
runSearch(searchInput.value.trim(), 1);
|
|
});
|
|
}
|
|
|
|
if (videoData.results.length) {
|
|
const videoSection = document.createElement("section");
|
|
videoSection.innerHTML = `
|
|
<div class="mb-3 flex max-w-3xl items-center justify-between">
|
|
<h2 class="text-sm font-semibold text-app-ink">Videos</h2>
|
|
<button id="seeAllVideosBtn" class="text-sm font-medium text-app-primary hover:underline">See all</button>
|
|
</div>
|
|
`;
|
|
const list = document.createElement("div");
|
|
list.className = "max-w-3xl space-y-3";
|
|
videoData.results.slice(0, 3).forEach((result) => {
|
|
const thumb = videoThumbnail(result.url);
|
|
const card = document.createElement("a");
|
|
card.href = result.url;
|
|
card.target = "_blank";
|
|
card.rel = "noreferrer noopener";
|
|
card.className = "block overflow-hidden rounded-lg border border-app-border bg-white transition hover:border-app-primary sm:flex";
|
|
card.innerHTML = `
|
|
<div class="h-36 w-full shrink-0 overflow-hidden bg-app-soft sm:w-56">
|
|
${
|
|
thumb
|
|
? `<img src="${escapeHTML(thumb)}" alt="${escapeHTML(result.title)}" class="h-full w-full object-cover" loading="lazy" />`
|
|
: `<div class="flex h-full items-center justify-center text-app-muted">Video</div>`
|
|
}
|
|
</div>
|
|
<div class="space-y-2 p-4">
|
|
<p class="text-xs uppercase text-app-success">${escapeHTML(extractHost(result.url))}</p>
|
|
<h3 class="text-lg font-medium text-app-primary">${escapeHTML(result.title)}</h3>
|
|
<p class="text-sm text-app-muted">${escapeHTML(extractHost(result.page_url))}</p>
|
|
</div>
|
|
`;
|
|
list.appendChild(card);
|
|
});
|
|
videoSection.appendChild(list);
|
|
resultsContainer.appendChild(videoSection);
|
|
videoSection.querySelector("#seeAllVideosBtn").addEventListener("click", () => {
|
|
currentType = "video";
|
|
runSearch(searchInput.value.trim(), 1);
|
|
});
|
|
}
|
|
|
|
renderPagination(webData.total, page, webData.query);
|
|
fetchAIAnswer(webData.query);
|
|
}
|
|
|
|
function renderVerticalMode(data, page) {
|
|
const start = (page - 1) * RESULTS_PER_PAGE + 1;
|
|
const end = Math.min(start + data.results.length - 1, data.total);
|
|
if (data.total === 0) {
|
|
renderEmpty(data.query);
|
|
return;
|
|
}
|
|
|
|
metaText.textContent = `${start}-${end} of about ${data.total} ${data.type} results`;
|
|
|
|
if (data.type === "image") {
|
|
renderImageGrid(data.results);
|
|
} else if (data.type === "video") {
|
|
renderVideoCards(data.results);
|
|
} else {
|
|
resultsContainer.className = "mt-6";
|
|
resultsContainer.innerHTML = "";
|
|
resultsContainer.appendChild(renderWebList(data.results));
|
|
}
|
|
|
|
renderPagination(data.total, page, data.query);
|
|
}
|
|
|
|
function renderLoadingSkeleton() {
|
|
if (currentType === "image") {
|
|
resultsContainer.className = "mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4";
|
|
resultsContainer.innerHTML = Array.from({ length: 8 }).map(() => '<div class="skeleton aspect-square rounded-lg"></div>').join("");
|
|
metaText.textContent = "Searching images...";
|
|
} else if (currentType === "video") {
|
|
resultsContainer.className = "mt-6 max-w-3xl space-y-4";
|
|
resultsContainer.innerHTML = Array.from({ length: 4 })
|
|
.map(() => `
|
|
<div class="overflow-hidden rounded-lg border border-app-border bg-white">
|
|
<div class="skeleton h-36 w-full"></div>
|
|
<div class="space-y-3 p-4">
|
|
<div class="skeleton h-3 w-24 rounded-full"></div>
|
|
<div class="skeleton h-6 w-3/4 rounded-full"></div>
|
|
<div class="skeleton h-3 w-1/2 rounded-full"></div>
|
|
</div>
|
|
</div>
|
|
`)
|
|
.join("");
|
|
metaText.textContent = "Searching videos...";
|
|
} else {
|
|
aiPanel.classList.remove("hidden");
|
|
aiStatus.textContent = "Waiting for search results...";
|
|
aiAnswer.textContent = "";
|
|
aiSources.innerHTML = "";
|
|
resultsContainer.className = "mt-8 max-w-3xl space-y-6";
|
|
resultsContainer.innerHTML = Array.from({ length: 4 })
|
|
.map(() => `
|
|
<article class="space-y-3">
|
|
<div class="skeleton h-3 w-56 rounded-full"></div>
|
|
<div class="skeleton h-6 w-2/3 rounded-full"></div>
|
|
<div class="space-y-2">
|
|
<div class="skeleton h-3 w-full rounded-full"></div>
|
|
<div class="skeleton h-3 w-11/12 rounded-full"></div>
|
|
</div>
|
|
</article>
|
|
`)
|
|
.join("");
|
|
metaText.textContent = "Searching...";
|
|
}
|
|
}
|
|
|
|
async function runSearch(query, page = 1) {
|
|
const normalizedQuery = query.trim();
|
|
if (!normalizedQuery) {
|
|
aiPanel.classList.add("hidden");
|
|
metaText.textContent = "Enter a search query.";
|
|
resultsContainer.className = "mt-6";
|
|
resultsContainer.innerHTML = `
|
|
<section class="max-w-2xl rounded-lg border border-app-border bg-app-bg px-5 py-6 text-sm text-app-muted">
|
|
Type a query above and press Search.
|
|
</section>
|
|
`;
|
|
paginationNav.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
updateTabsUI();
|
|
updateUrl(normalizedQuery, page);
|
|
searchInput.value = normalizedQuery;
|
|
renderLoadingSkeleton();
|
|
paginationNav.innerHTML = "";
|
|
|
|
const offset = (page - 1) * RESULTS_PER_PAGE;
|
|
|
|
try {
|
|
if (currentType === "all") {
|
|
const [webData, imageData, videoData] = await Promise.all([
|
|
fetchSearch("web", normalizedQuery, RESULTS_PER_PAGE, offset),
|
|
fetchSearch("image", normalizedQuery, 8, offset),
|
|
fetchSearch("video", normalizedQuery, 6, offset),
|
|
]);
|
|
renderAllMode(webData, imageData, videoData, page);
|
|
} else {
|
|
const data = await fetchSearch(currentType, normalizedQuery, RESULTS_PER_PAGE, offset);
|
|
renderVerticalMode(data, page);
|
|
}
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
} catch (error) {
|
|
renderError(error.message || "The search request failed.");
|
|
}
|
|
}
|
|
|
|
tabAll.addEventListener("click", () => {
|
|
if (currentType !== "all") {
|
|
currentType = "all";
|
|
runSearch(searchInput.value || getQueryFromUrl(), 1);
|
|
}
|
|
});
|
|
|
|
tabImages.addEventListener("click", () => {
|
|
if (currentType !== "image") {
|
|
currentType = "image";
|
|
runSearch(searchInput.value || getQueryFromUrl(), 1);
|
|
}
|
|
});
|
|
|
|
tabVideos.addEventListener("click", () => {
|
|
if (currentType !== "video") {
|
|
currentType = "video";
|
|
runSearch(searchInput.value || getQueryFromUrl(), 1);
|
|
}
|
|
});
|
|
|
|
searchForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
currentType = "all";
|
|
runSearch(searchInput.value, 1);
|
|
});
|
|
|
|
aiRegenerate.addEventListener("click", () => {
|
|
const query = searchInput.value || getQueryFromUrl();
|
|
if (query.trim()) {
|
|
fetchAIAnswer(query.trim());
|
|
}
|
|
});
|
|
|
|
aiModelSelect.addEventListener("change", () => {
|
|
const query = searchInput.value || getQueryFromUrl();
|
|
if (currentType === "all" && query.trim()) {
|
|
updateUrl(query.trim(), getPageFromUrl());
|
|
}
|
|
});
|
|
|
|
closeModalBtn.addEventListener("click", closeImageModal);
|
|
imageModal.addEventListener("click", (event) => {
|
|
if (event.target === imageModal) {
|
|
closeImageModal();
|
|
}
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && !imageModal.classList.contains("hidden")) {
|
|
closeImageModal();
|
|
}
|
|
});
|
|
|
|
async function init() {
|
|
await loadModels();
|
|
currentType = getTypeFromUrl();
|
|
runSearch(getQueryFromUrl(), getPageFromUrl());
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|