mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Harden Docker runtime packaging, preserve async job response semantics, fix Redis subscriber startup connections, clear lint warnings, and harden enterprise S3 object body handling.
159 lines
5.5 KiB
Plaintext
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>
|