Files
SnapOtter/apps/landing/src/components/HeroSearch.astro
T
SnapOtterandGitHub 6ecc598fc4 fix(landing): link English-only tool-detail and self-hosted pages to un-prefixed URLs (#553)
Tool-detail pages (/tools/<section>/<tool>/) and the /self-hosted pages are
built only in English, with no per-locale route, so a locale-prefixed link
404s in the static build. Add an enOnlyHref() helper and use it for those
links in Footer, Navbar, HeroSearch, and ToolGrid so localized pages point at
the English pages that actually exist. Adds an e2e guard asserting localized
pages emit un-prefixed URLs for those routes.
2026-07-18 00:01:03 +08:00

172 lines
6.1 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";
import { t } from "@/i18n";
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
import { loadToolStrings } from "@/lib/tool-strings";
interface Props {
locale?: string;
}
const { locale = "en" } = Astro.props;
// Tool name/description come from shared i18n (translated in all 21 langs).
const toolStrings = await loadToolStrings(locale);
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={t(locale, "home.hero.searchPlaceholder")}
class="w-full rounded-2xl border border-border bg-surface py-4 pe-4 ps-12 text-base shadow-sm outline-none transition-all placeholder:text-muted focus:border-primary focus:shadow-md"
aria-label={t(locale, "home.hero.searchPlaceholder")}
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];
const toolName = toolStrings[tool.id]?.name ?? tool.name;
const toolDescription = toolStrings[tool.id]?.description ?? tool.description;
return (
<li
class="hero-result"
data-name={toolName.toLowerCase()}
data-desc={toolDescription.toLowerCase()}
data-mod={(mod?.label || "").toLowerCase()}
data-keywords={(tool.keywords ?? []).join(" ")}
hidden
>
<a
href={enOnlyHref(`/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">{toolName}</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">
{t(locale, "home.hero.searchEmpty")}
</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>