Files
SnapOtter/apps/landing/src/components/HeroSearch.astro
T
SnapOtterandGitHub f3342a1e57 fix: harden Docker image and async job responses
Harden Docker runtime packaging, preserve async job response semantics, fix Redis subscriber startup connections, clear lint warnings, and harden enterprise S3 object body handling.
2026-07-01 12:32:33 +08:00

159 lines
5.5 KiB
Plaintext

---
// biome-ignore-all lint/correctness/noUnusedImports: Astro template consumes component imports.
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
import { TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
const MODALITY_META: Record<string, { label: string; color: string }> = {
image: { label: "Image", color: "#E07832" },
video: { label: "Video", color: "#BE4A3C" },
audio: { label: "Audio", color: "#2C7A75" },
document: { label: "PDF", color: "#44568C" },
file: { label: "Files", color: "#5C8642" },
};
function renderIcon(iconName: string): string {
const iconData = (lucideIcons as Record<string, unknown>)[iconName];
if (!iconData || !Array.isArray(iconData)) return "";
return iconData
.map(([tag, attrs]: [string, Record<string, string>]) => {
const attrStr = Object.entries(attrs)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
return `<${tag} ${attrStr}/>`;
})
.join("");
}
---
<div class="hero-search animate-fade-up relative z-30 mx-auto mt-8 max-w-xl" style="animation-delay: 0.18s;">
<div class="relative">
<svg class="pointer-events-none absolute top-1/2 left-5 h-5 w-5 -translate-y-1/2 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input
type="text"
id="hero-tool-search"
autocomplete="off"
placeholder="Search tools"
class="w-full rounded-2xl border border-border bg-surface py-4 pr-4 pl-12 text-base shadow-sm outline-none transition-all placeholder:text-muted focus:border-primary focus:shadow-md"
aria-label="Search tools"
aria-expanded="false"
aria-controls="hero-search-results"
/>
</div>
<!-- Live results dropdown -->
<div
id="hero-search-results"
class="absolute z-20 mt-2 hidden w-full overflow-hidden rounded-2xl border border-border bg-surface text-start shadow-xl"
>
<ul id="hero-search-list" class="max-h-80 overflow-y-auto py-1.5">
{TOOLS.map((tool) => {
const mod = MODALITY_META[tool.modality];
return (
<li
class="hero-result"
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}
data-mod={(mod?.label || "").toLowerCase()}
data-keywords={(tool.keywords ?? []).join(" ")}
hidden
>
<a
href={`/tools/${toolSection(tool)}/${tool.id}/`}
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
>
<svg
class="h-5 w-5 shrink-0"
style={{ color: mod?.color }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
set:html={renderIcon(tool.icon)}
/>
<span class="flex-1 text-sm font-medium text-foreground">{tool.name}</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={`background-color: ${mod?.color}1a; color: ${mod?.color}`}
>
{mod?.label}
</span>
</a>
</li>
);
})}
</ul>
<p id="hero-search-empty" class="hidden px-4 py-3 text-sm text-muted">
No tools match. Try "resize", "convert", or "compress".
</p>
</div>
</div>
<script>
import { matchTool } from "../lib/tool-search";
const input = document.getElementById("hero-tool-search") as HTMLInputElement | null;
const box = document.getElementById("hero-search-results");
const empty = document.getElementById("hero-search-empty");
const items = Array.from(
document.querySelectorAll<HTMLLIElement>("#hero-search-list .hero-result"),
);
const MAX = 7;
let firstHref: string | null = null;
if (input && box && empty) {
const close = () => {
box.classList.add("hidden");
input.setAttribute("aria-expanded", "false");
};
const open = () => {
box.classList.remove("hidden");
input.setAttribute("aria-expanded", "true");
};
const run = () => {
const q = input.value.trim();
if (!q) {
close();
return;
}
let shown = 0;
firstHref = null;
for (const li of items) {
const hay =
`${li.getAttribute("data-name") || ""} ${li.getAttribute("data-desc") || ""} ` +
`${li.getAttribute("data-mod") || ""} ${li.getAttribute("data-keywords") || ""}`;
const hit = matchTool(q, hay);
if (hit && shown < MAX) {
li.hidden = false;
shown++;
if (!firstHref) firstHref = li.querySelector("a")?.getAttribute("href") ?? null;
} else {
li.hidden = true;
}
}
empty.classList.toggle("hidden", shown !== 0);
open();
};
input.addEventListener("input", run);
input.addEventListener("focus", () => {
if (input.value.trim()) run();
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && firstHref) {
window.location.href = firstHref;
} else if (e.key === "Escape") {
close();
input.blur();
}
});
document.addEventListener("click", (e) => {
if (!box.contains(e.target as Node) && e.target !== input) close();
});
}
</script>