mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
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 { 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={localizeHref(locale, `/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>
|