inital commit

This commit is contained in:
Ned Halksworth
2026-05-04 19:31:46 +01:00
commit e0f2eedcd9
14 changed files with 3718 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sFetch</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
sfetch: {
bg: "#f8fafc",
surface: "#ffffff",
surfaceSoft: "#f1f5f9",
ink: "#202124",
muted: "#5f6368",
border: "#dadce0",
blue: "#1a73e8",
orange: "#de5833",
green: "#0b8043",
},
},
boxShadow: {
search: "0 2px 8px rgba(60, 64, 67, 0.14), 0 1px 3px rgba(60, 64, 67, 0.12)",
panel: "0 16px 40px rgba(15, 23, 42, 0.08)",
},
},
},
};
</script>
<style>
:root {
color-scheme: light;
}
body {
background: #f8fafc;
color: #202124;
font-family: Arial, Helvetica, sans-serif;
}
.brand {
font-family: Arial, Helvetica, sans-serif;
font-weight: 700;
letter-spacing: 0;
}
.brand span:nth-child(1) { color: #de5833; }
.brand span:nth-child(2) { color: #1a73e8; }
.brand span:nth-child(3) { color: #188038; }
.brand span:nth-child(4) { color: #fbbc04; }
.brand span:nth-child(5) { color: #1a73e8; }
.brand span:nth-child(6) { color: #de5833; }
.modal-open {
overflow: hidden;
}
</style>
</head>
<body class="min-h-screen">
<main class="flex min-h-screen flex-col">
<header class="flex items-center justify-between px-5 py-4 text-sm text-sfetch-muted sm:px-8">
<a href="./index.html" class="brand text-2xl" aria-label="sFetch home">
<span>s</span><span>F</span><span>e</span><span>t</span><span>c</span><span>h</span>
</a>
<button
id="openCrawlerModal"
class="rounded-full border border-sfetch-border bg-white px-4 py-2 font-medium text-sfetch-ink transition hover:border-sfetch-orange hover:text-sfetch-orange"
>
Index tools
</button>
</header>
<section class="mx-auto flex w-full max-w-5xl flex-1 flex-col items-center justify-center px-5 pb-24 pt-10">
<h1 class="brand text-center text-6xl leading-none sm:text-7xl">
<span>s</span><span>F</span><span>e</span><span>t</span><span>c</span><span>h</span>
</h1>
<form id="searchForm" class="mt-9 w-full max-w-2xl">
<label
for="searchInput"
class="flex min-h-14 items-center gap-3 rounded-full border border-sfetch-border bg-white px-5 transition focus-within:border-transparent focus-within:shadow-search"
>
<svg class="h-5 w-5 shrink-0 text-sfetch-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"
placeholder="Search sFetch"
class="w-full bg-transparent text-base text-sfetch-ink outline-none placeholder:text-sfetch-muted sm:text-lg"
/>
</label>
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
<button
type="submit"
class="rounded-md bg-sfetch-blue px-5 py-2.5 text-sm font-medium text-white transition hover:bg-[#1558b0]"
>
sFetch Search
</button>
<button
type="button"
data-search-type="image"
class="rounded-md border border-sfetch-border bg-white px-5 py-2.5 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-blue hover:text-sfetch-blue"
>
Images
</button>
<button
type="button"
data-search-type="video"
class="rounded-md border border-sfetch-border bg-white px-5 py-2.5 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-blue hover:text-sfetch-blue"
>
Videos
</button>
</div>
</form>
<section class="mt-12 w-full max-w-3xl rounded-lg border border-sfetch-border bg-white p-4 shadow-panel" aria-label="Index controls">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase text-sfetch-orange">Index</p>
<p id="statsSummary" class="mt-1 text-sm text-sfetch-muted">Checking index...</p>
</div>
<div class="flex flex-wrap gap-2">
<button
id="seedTopSites"
class="rounded-md bg-sfetch-orange px-4 py-2 text-sm font-medium text-white transition hover:bg-[#c44724]"
>
Seed top 1000
</button>
<button
id="openCrawlerModalSecondary"
class="rounded-md border border-sfetch-border bg-white px-4 py-2 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-orange hover:text-sfetch-orange"
>
Custom crawl
</button>
</div>
</div>
<div class="mt-4 h-2 overflow-hidden rounded-full bg-sfetch-surfaceSoft">
<div id="seedProgress" class="h-full w-0 bg-sfetch-orange transition-all duration-300"></div>
</div>
<p id="seedStatus" class="mt-3 min-h-5 text-sm text-sfetch-muted">Top-site seed status unavailable.</p>
</section>
</section>
<footer class="border-t border-sfetch-border bg-white px-5 py-4 text-center text-xs text-sfetch-muted">
&copy; 2026 sFetch
</footer>
</main>
<div
id="crawlerModal"
class="pointer-events-none fixed inset-0 z-30 flex items-center justify-center bg-slate-900/35 px-4 opacity-0 transition"
aria-hidden="true"
>
<div class="w-full max-w-xl rounded-lg border border-sfetch-border bg-white p-5 shadow-panel">
<div class="flex items-center justify-between gap-4 border-b border-sfetch-border pb-4">
<h2 class="text-lg font-semibold text-sfetch-ink">Custom crawl</h2>
<button
id="closeCrawlerModal"
class="flex h-9 w-9 items-center justify-center rounded-full text-sfetch-muted transition hover:bg-sfetch-surfaceSoft hover:text-sfetch-ink"
aria-label="Close crawler modal"
>
X
</button>
</div>
<form id="crawlerForm" class="mt-5 space-y-4">
<div>
<label for="seedUrls" class="mb-2 block text-sm font-medium text-sfetch-ink">Seed URLs</label>
<textarea
id="seedUrls"
rows="6"
placeholder="https://example.com&#10;https://docs.python.org/"
class="w-full rounded-md border border-sfetch-border bg-white px-3 py-2 text-sm text-sfetch-ink outline-none transition focus:border-sfetch-blue focus:ring-2 focus:ring-blue-100"
></textarea>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="crawlDepth" class="mb-2 block text-sm font-medium text-sfetch-ink">Max depth</label>
<input
id="crawlDepth"
type="number"
min="0"
max="5"
value="2"
class="w-full rounded-md border border-sfetch-border bg-white px-3 py-2 text-sm text-sfetch-ink outline-none transition focus:border-sfetch-blue focus:ring-2 focus:ring-blue-100"
/>
</div>
<div>
<label for="maxPagesPerDomain" class="mb-2 block text-sm font-medium text-sfetch-ink">Pages per domain</label>
<input
id="maxPagesPerDomain"
type="number"
min="1"
max="500"
value="50"
class="w-full rounded-md border border-sfetch-border bg-white px-3 py-2 text-sm text-sfetch-ink outline-none transition focus:border-sfetch-blue focus:ring-2 focus:ring-blue-100"
/>
</div>
</div>
<label class="flex items-center gap-3 text-sm text-sfetch-ink">
<input id="sameDomainOnly" type="checkbox" checked class="h-4 w-4 rounded border-sfetch-border text-sfetch-blue" />
Same domain only
</label>
<p id="crawlerStatus" class="min-h-5 text-sm text-sfetch-muted"></p>
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
id="cancelCrawler"
class="rounded-md border border-sfetch-border bg-white px-4 py-2 text-sm font-medium text-sfetch-ink transition hover:bg-sfetch-surfaceSoft"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-sfetch-blue px-4 py-2 text-sm font-medium text-white transition hover:bg-[#1558b0]"
>
Launch crawl
</button>
</div>
</form>
</div>
</div>
<script>
const API_BASE = "http://localhost:8000";
const searchForm = document.getElementById("searchForm");
const searchInput = document.getElementById("searchInput");
const openCrawlerModal = document.getElementById("openCrawlerModal");
const openCrawlerModalSecondary = document.getElementById("openCrawlerModalSecondary");
const closeCrawlerModal = document.getElementById("closeCrawlerModal");
const cancelCrawler = document.getElementById("cancelCrawler");
const crawlerModal = document.getElementById("crawlerModal");
const crawlerForm = document.getElementById("crawlerForm");
const crawlerStatus = document.getElementById("crawlerStatus");
const seedUrlsField = document.getElementById("seedUrls");
const crawlDepthField = document.getElementById("crawlDepth");
const maxPagesPerDomainField = document.getElementById("maxPagesPerDomain");
const sameDomainOnlyField = document.getElementById("sameDomainOnly");
const statsSummary = document.getElementById("statsSummary");
const seedStatus = document.getElementById("seedStatus");
const seedProgress = document.getElementById("seedProgress");
const seedTopSites = document.getElementById("seedTopSites");
function runSearch(type = "all") {
const query = searchInput.value.trim();
if (!query) {
searchInput.focus();
return;
}
const params = new URLSearchParams({ q: query });
if (type !== "all") {
params.set("type", type);
}
window.location.href = `results.html?${params.toString()}`;
}
function setModalOpen(isOpen) {
crawlerModal.classList.toggle("opacity-0", !isOpen);
crawlerModal.classList.toggle("pointer-events-none", !isOpen);
crawlerModal.setAttribute("aria-hidden", String(!isOpen));
document.body.classList.toggle("modal-open", isOpen);
if (isOpen) {
seedUrlsField.focus();
} else {
crawlerStatus.textContent = "";
}
}
async function refreshStats() {
try {
const response = await fetch(`${API_BASE}/stats`);
const stats = await response.json();
if (!response.ok) {
throw new Error();
}
const lastIndexed = stats.last_indexed_at ? `, last indexed ${stats.last_indexed_at}` : "";
statsSummary.textContent = `${stats.total_pages.toLocaleString()} pages${lastIndexed}`;
} catch {
statsSummary.textContent = "Backend unavailable";
}
}
async function refreshSeedStatus() {
try {
const response = await fetch(`${API_BASE}/crawl/top-sites/status`);
const status = await response.json();
if (!response.ok) {
throw new Error();
}
const total = Number(status.total || 0);
const indexed = Number(status.indexed || 0);
const percent = total > 0 && status.state === "complete" ? 100 : total > 0 ? Math.min(96, (indexed / total) * 100) : 0;
seedProgress.style.width = `${percent}%`;
seedStatus.textContent = `${status.message || "Idle"}${status.source ? ` Source: ${status.source}` : ""}`;
} catch {
seedProgress.style.width = "0%";
seedStatus.textContent = "Top-site seed status unavailable.";
}
}
async function seedTopSitesNow() {
seedTopSites.disabled = true;
seedTopSites.textContent = "Queued";
try {
const response = await fetch(`${API_BASE}/crawl/top-sites`, { method: "POST" });
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || "Unable to queue top-site seed.");
}
seedStatus.textContent = "Top-site seed queued.";
await refreshSeedStatus();
} catch (error) {
seedStatus.textContent = error.message || "Unable to queue top-site seed.";
} finally {
setTimeout(() => {
seedTopSites.disabled = false;
seedTopSites.textContent = "Seed top 1000";
}, 1200);
}
}
async function handleCrawlerSubmit(event) {
event.preventDefault();
const seedUrls = seedUrlsField.value
.split("\n")
.map((value) => value.trim())
.filter(Boolean);
if (!seedUrls.length) {
crawlerStatus.textContent = "Add at least one seed URL.";
return;
}
const payload = {
seed_urls: seedUrls,
max_depth: Number.parseInt(crawlDepthField.value, 10) || 0,
max_pages_per_domain: Number.parseInt(maxPagesPerDomainField.value, 10) || 1,
same_domain_only: sameDomainOnlyField.checked,
};
crawlerStatus.textContent = "Starting crawl...";
try {
const response = await fetch(`${API_BASE}/crawl`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || "Unable to start the crawler.");
}
crawlerStatus.textContent = `Crawl started for ${seedUrls.length} seed URL${seedUrls.length === 1 ? "" : "s"}.`;
setTimeout(() => {
setModalOpen(false);
refreshStats();
}, 900);
} catch (error) {
crawlerStatus.textContent = error.message || "Unable to start the crawler.";
}
}
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
runSearch("all");
});
document.querySelectorAll("[data-search-type]").forEach((button) => {
button.addEventListener("click", () => runSearch(button.dataset.searchType || "all"));
});
openCrawlerModal.addEventListener("click", () => setModalOpen(true));
openCrawlerModalSecondary.addEventListener("click", () => setModalOpen(true));
closeCrawlerModal.addEventListener("click", () => setModalOpen(false));
cancelCrawler.addEventListener("click", () => setModalOpen(false));
crawlerModal.addEventListener("click", (event) => {
if (event.target === crawlerModal) {
setModalOpen(false);
}
});
seedTopSites.addEventListener("click", seedTopSitesNow);
crawlerForm.addEventListener("submit", handleCrawlerSubmit);
refreshStats();
refreshSeedStatus();
setInterval(refreshStats, 10000);
setInterval(refreshSeedStatus, 5000);
</script>
</body>
</html>
+693
View File
@@ -0,0 +1,693 @@
<!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: {
sfetch: {
bg: "#f8fafc",
surface: "#ffffff",
surfaceSoft: "#f1f5f9",
ink: "#202124",
muted: "#5f6368",
border: "#dadce0",
blue: "#1a73e8",
orange: "#de5833",
green: "#0b8043",
},
},
boxShadow: {
search: "0 2px 8px rgba(60, 64, 67, 0.14), 0 1px 3px rgba(60, 64, 67, 0.12)",
panel: "0 16px 40px rgba(15, 23, 42, 0.08)",
},
},
},
};
</script>
<style>
:root {
color-scheme: light;
}
body {
background: #ffffff;
color: #202124;
font-family: Arial, Helvetica, sans-serif;
}
.brand {
font-family: Arial, Helvetica, sans-serif;
font-weight: 700;
letter-spacing: 0;
}
.brand span:nth-child(1) { color: #de5833; }
.brand span:nth-child(2) { color: #1a73e8; }
.brand span:nth-child(3) { color: #188038; }
.brand span:nth-child(4) { color: #fbbc04; }
.brand span:nth-child(5) { color: #1a73e8; }
.brand span:nth-child(6) { color: #de5833; }
.skeleton {
background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 37%, #eef2f7 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
mark {
background: rgba(251, 188, 4, 0.28);
color: #202124;
padding: 0 0.12rem;
border-radius: 0.2rem;
}
@keyframes shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
@keyframes barrel-roll {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
}
.barrel-roll {
animation: barrel-roll 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
</style>
</head>
<body class="min-h-screen">
<div class="min-h-screen">
<header class="sticky top-0 z-20 border-b border-sfetch-border bg-white/95 backdrop-blur">
<div class="mx-auto flex max-w-6xl flex-col gap-4 px-5 py-4 sm:flex-row sm:items-center">
<a href="./index.html" class="brand text-3xl leading-none" aria-label="sFetch home">
<span>s</span><span>F</span><span>e</span><span>t</span><span>c</span><span>h</span>
</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-full border border-sfetch-border bg-white px-4 transition focus-within:border-transparent focus-within:shadow-search"
>
<svg class="h-5 w-5 shrink-0 text-sfetch-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-sfetch-ink outline-none placeholder:text-sfetch-muted"
placeholder="Search sFetch"
/>
</label>
<button
id="searchButton"
type="submit"
class="rounded-md bg-sfetch-blue px-5 py-3 text-sm font-medium text-white transition hover:bg-[#1558b0]"
>
Search
</button>
</form>
<a
href="./index.html"
class="rounded-md border border-sfetch-border bg-white px-4 py-2 text-sm font-medium text-sfetch-ink transition hover:border-sfetch-orange hover:text-sfetch-orange"
>
Index tools
</a>
</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-sfetch-muted">All</button>
<button id="tabImages" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-sfetch-muted">Images</button>
<button id="tabVideos" class="tab-btn border-b-2 border-transparent pb-3 font-medium text-sfetch-muted">Videos</button>
</nav>
</header>
<main class="mx-auto max-w-6xl px-5 py-8">
<p id="metaText" class="text-sm text-sfetch-muted"></p>
<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>
<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-sfetch-border bg-white shadow-panel">
<div class="flex items-center justify-between border-b border-sfetch-border px-6 py-4">
<h3 id="modalTitle" class="truncate text-base font-medium text-sfetch-ink">Image preview</h3>
<button id="closeModal" class="flex h-9 w-9 items-center justify-center rounded-full text-sfetch-muted transition hover:bg-sfetch-surfaceSoft hover:text-sfetch-ink">
X
</button>
</div>
<div class="h-[calc(100vh-73px)] overflow-y-auto px-6 py-5">
<div class="overflow-hidden rounded-lg bg-sfetch-surfaceSoft">
<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-sfetch-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 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function getTypeFromUrl() {
const typeValue = new URLSearchParams(window.location.search).get("type");
if (typeValue === "image" || typeValue === "video" || typeValue === "all") {
return typeValue;
}
return "all";
}
function getQueryFromUrl() {
return (new URLSearchParams(window.location.search).get("q") || "").trim();
}
function getPageFromUrl() {
const raw = new URLSearchParams(window.location.search).get("page") || "1";
const page = Number.parseInt(raw, 10);
return Number.isNaN(page) || page < 1 ? 1 : page;
}
function updateUrl(query, page) {
const params = new URLSearchParams(window.location.search);
params.set("q", query);
page > 1 ? params.set("page", String(page)) : params.delete("page");
currentType === "all" ? params.delete("type") : params.set("type", currentType);
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-sfetch-orange", active);
tab.classList.toggle("text-sfetch-ink", active);
tab.classList.toggle("border-transparent", !active);
tab.classList.toggle("text-sfetch-muted", !active);
});
}
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;
}
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";
resultsContainer.className = "mt-6";
resultsContainer.innerHTML = `
<section class="max-w-2xl rounded-lg border border-sfetch-border bg-sfetch-bg px-5 py-6">
<p class="text-lg text-sfetch-ink">Unable to load results.</p>
<p class="mt-2 text-sm text-sfetch-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-sfetch-border bg-sfetch-bg px-5 py-8">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-sfetch-surfaceSoft text-lg font-bold text-sfetch-orange">s</div>
<h2 class="mt-4 text-xl text-sfetch-ink">No results found</h2>
<p class="mt-2 text-sm text-sfetch-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-sfetch-blue bg-sfetch-blue text-white"
: disabled
? "cursor-not-allowed border-sfetch-border text-sfetch-muted/50"
: "border-sfetch-border text-sfetch-ink hover:border-sfetch-blue hover:text-sfetch-blue"
}`;
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-sfetch-border transition hover:border-sfetch-orange";
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) {
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-sfetch-border bg-white transition hover:border-sfetch-orange";
card.innerHTML = `
<div class="aspect-square overflow-hidden bg-sfetch-surfaceSoft">
<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-sfetch-muted">${escapeHTML(result.alt_text || extractHost(result.page_url))}</div>
`;
card.addEventListener("click", () => openImageModal(result, index, results));
resultsContainer.appendChild(card);
});
}
function renderVideoCards(results) {
resultsContainer.className = "mt-6 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-sfetch-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-sfetch-surfaceSoft 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-sfetch-muted">Video</div>`
}
</div>
<div class="space-y-2 p-5">
<p class="text-xs uppercase text-sfetch-green">${escapeHTML(extractHost(result.url))}</p>
<h3 class="text-xl font-medium text-sfetch-blue">${escapeHTML(result.title)}</h3>
<p class="text-sm text-sfetch-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-sfetch-muted">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-sfetch-surfaceSoft text-xs font-bold text-sfetch-orange">${escapeHTML(host.slice(0, 1).toUpperCase())}</div>
<div class="min-w-0">
<p class="text-sfetch-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-sfetch-blue hover:underline"
>${escapeHTML(result.title)}</a>
<p class="text-sm leading-6 text-sfetch-muted">${result.snippet}</p>
`;
wrapper.appendChild(article);
});
return wrapper;
}
function renderAllMode(webData, imageData, videoData, page) {
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);
return;
}
metaText.textContent = webData.total > 0
? `${start}-${end} of about ${webData.total} web results`
: "No direct web matches, showing media results";
resultsContainer.className = "mt-6 space-y-9";
resultsContainer.innerHTML = "";
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-sfetch-ink">Images</h2>
<button id="seeAllImagesBtn" class="text-sm font-medium text-sfetch-blue 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-sfetch-border bg-sfetch-surfaceSoft";
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 (webData.results.length) {
resultsContainer.appendChild(renderWebList(webData.results));
}
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-sfetch-ink">Videos</h2>
<button id="seeAllVideosBtn" class="text-sm font-medium text-sfetch-blue 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-sfetch-border bg-white transition hover:border-sfetch-orange sm:flex";
card.innerHTML = `
<div class="h-36 w-full shrink-0 overflow-hidden bg-sfetch-surfaceSoft 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-sfetch-muted">Video</div>`
}
</div>
<div class="space-y-2 p-4">
<p class="text-xs uppercase text-sfetch-green">${escapeHTML(extractHost(result.url))}</p>
<h3 class="text-lg font-medium text-sfetch-blue">${escapeHTML(result.title)}</h3>
<p class="text-sm text-sfetch-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);
}
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-sfetch-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 {
resultsContainer.className = "mt-6 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) {
metaText.textContent = "Enter a search query.";
resultsContainer.className = "mt-6";
resultsContainer.innerHTML = `
<section class="max-w-2xl rounded-lg border border-sfetch-border bg-sfetch-bg px-5 py-6 text-sm text-sfetch-muted">
Type a query above and press Search.
</section>
`;
paginationNav.innerHTML = "";
return;
}
if (normalizedQuery.toLowerCase() === "do a barrel roll") {
document.documentElement.classList.add("barrel-roll");
setTimeout(() => document.documentElement.classList.remove("barrel-roll"), 1200);
}
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();
runSearch(searchInput.value, 1);
});
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();
}
});
currentType = getTypeFromUrl();
runSearch(getQueryFromUrl(), getPageFromUrl());
</script>
</body>
</html>