mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
172 lines
6.1 KiB
Plaintext
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>
|