Files
SnapOtter/apps/landing/src/components/ToolGrid.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

196 lines
7.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 { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
import * as lucideIcons from "lucide";
import SectionHeading from "./SectionHeading.astro";
const toolCount = TOOLS.length;
const categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
const modalities = [
{ id: "all", label: "All", count: TOOLS.length },
{ id: "image", label: "Image", count: TOOLS.filter((t) => t.modality === "image").length },
{ id: "video", label: "Video", count: TOOLS.filter((t) => t.modality === "video").length },
{ id: "audio", label: "Audio", count: TOOLS.filter((t) => t.modality === "audio").length },
{
id: "document,file",
label: "PDF & Files",
count: TOOLS.filter((t) => t.modality === "document" || t.modality === "file").length,
},
];
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("");
}
---
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
<SectionHeading
title="One platform. Every file tool."
subtitle={`Browse ${toolCount} self-hosted tools across image, video, audio, documents, and data.`}
/>
<div class="mx-auto max-w-6xl">
<!-- Modality pills -->
<div class="flex flex-wrap items-center justify-center gap-2" id="modality-pills">
{modalities.map((m) => (
<button
type="button"
data-modality={m.id}
class:list={[
"tool-pill shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors",
m.id === "all"
? "bg-primary text-white"
: "border border-border bg-surface hover:bg-background-emphasis",
]}
>
{m.label} ({m.count})
</button>
))}
</div>
<!-- Category pills -->
<div class="mt-3 flex flex-wrap items-center justify-center gap-2" id="category-pills">
{CATEGORIES.map((cat) => {
const count = TOOLS.filter((t) => t.category === cat.id).length;
return (
<button
type="button"
data-category={cat.id}
class="tool-pill shrink-0 rounded-full border border-border bg-surface px-3 py-1 text-xs font-medium transition-colors hover:bg-background-emphasis"
>
{cat.name} ({count})
</button>
);
})}
</div>
<!-- Result count -->
<p class="mt-6 text-center text-sm text-muted" id="tool-count">
Showing {toolCount} of {toolCount} tools
</p>
<!-- Tool grid -->
<div class="mt-8 grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5" id="tool-grid">
{TOOLS.map((tool) => {
const cat = categoryMap.get(tool.category);
return (
<a
href={`/tools/${toolSection(tool)}/${tool.id}/`}
class="tool-card flex flex-col items-center rounded-xl border border-border bg-surface px-4 py-5 text-center no-underline transition-all"
data-name={tool.name.toLowerCase()}
data-desc={tool.description.toLowerCase()}
data-modality={tool.modality}
data-category={tool.category}
style={{ "--cat-color": cat?.color } as any}
>
<svg
class="mb-2.5 h-7 w-7 shrink-0"
style={{ color: cat?.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="text-sm font-semibold text-foreground">{tool.name}</span>
<p class="mt-1 text-xs leading-relaxed text-muted line-clamp-2">{tool.description}</p>
</a>
);
})}
</div>
<!-- Empty state -->
<p class="mt-8 hidden text-center text-muted" id="tool-empty">
No tools match these filters.
</p>
<!-- Browse all link -->
<div class="mt-10 text-center">
<a href="/tools" class="inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline">
Browse full tool catalog
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</a>
</div>
</div>
</section>
<script is:inline>
(function () {
var grid = document.getElementById("tool-grid");
var countEl = document.getElementById("tool-count");
var emptyEl = document.getElementById("tool-empty");
var cards = grid.querySelectorAll(".tool-card");
var total = cards.length;
var activeModality = "all";
var activeCategory = "";
function updatePillStyles() {
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
var m = btn.getAttribute("data-modality");
if (m === activeModality) {
btn.className = "tool-pill shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors bg-primary text-white";
} else {
btn.className = "tool-pill shrink-0 rounded-full px-4 py-1.5 text-sm font-medium transition-colors border border-border bg-surface hover:bg-background-emphasis";
}
});
document.querySelectorAll("#category-pills .tool-pill").forEach(function (btn) {
var c = btn.getAttribute("data-category");
if (c === activeCategory) {
btn.className = "tool-pill shrink-0 rounded-full px-3 py-1 text-xs font-medium transition-colors bg-primary text-white";
} else {
btn.className = "tool-pill shrink-0 rounded-full border border-border bg-surface px-3 py-1 text-xs font-medium transition-colors hover:bg-background-emphasis";
}
});
}
function filter() {
var visible = 0;
cards.forEach(function (card) {
var mod = card.getAttribute("data-modality") || "";
var cat = card.getAttribute("data-category") || "";
var matchMod = activeModality === "all" || activeModality.split(",").indexOf(mod) !== -1;
var matchCat = !activeCategory || cat === activeCategory;
if (matchMod && matchCat) {
card.style.display = "";
visible++;
} else {
card.style.display = "none";
}
});
countEl.textContent = "Showing " + visible + " of " + total + " tools";
emptyEl.style.display = visible === 0 ? "block" : "none";
grid.style.display = visible === 0 ? "none" : "";
}
document.querySelectorAll("#modality-pills .tool-pill").forEach(function (btn) {
btn.addEventListener("click", function () {
activeModality = btn.getAttribute("data-modality");
updatePillStyles();
filter();
});
});
document.querySelectorAll("#category-pills .tool-pill").forEach(function (btn) {
btn.addEventListener("click", function () {
var cat = btn.getAttribute("data-category");
activeCategory = activeCategory === cat ? "" : cat;
updatePillStyles();
filter();
});
});
})();
</script>