mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Zero client JS, 165 static pages, self-hosted fonts, Otter Orange design system. Includes tool SEO data for all 157 tools, enterprise page, and updated e2e landing tests.
214 lines
8.3 KiB
Plaintext
214 lines
8.3 KiB
Plaintext
---
|
|
import { CATEGORIES, TOOLS } 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={`${toolCount} tools. One platform.`}
|
|
subtitle="Replace dozens of cloud subscriptions with a single deployment your team controls."
|
|
/>
|
|
|
|
<div class="mx-auto max-w-6xl">
|
|
<!-- Search bar -->
|
|
<div class="mx-auto max-w-md">
|
|
<div class="relative">
|
|
<svg class="absolute top-1/2 left-4 h-4 w-4 -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="tool-search"
|
|
placeholder="Search tools..."
|
|
class="w-full rounded-xl border border-border bg-surface py-3 pr-4 pl-11 text-sm outline-none transition-colors placeholder:text-muted focus:border-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modality pills -->
|
|
<div class="mt-6 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/${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 found. Try a different search.
|
|
</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 search = document.getElementById("tool-search");
|
|
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 q = search.value.toLowerCase().trim();
|
|
var visible = 0;
|
|
cards.forEach(function (card) {
|
|
var name = card.getAttribute("data-name") || "";
|
|
var desc = card.getAttribute("data-desc") || "";
|
|
var mod = card.getAttribute("data-modality") || "";
|
|
var cat = card.getAttribute("data-category") || "";
|
|
var matchSearch = !q || name.indexOf(q) !== -1 || desc.indexOf(q) !== -1;
|
|
var matchMod = activeModality === "all" || activeModality.split(",").indexOf(mod) !== -1;
|
|
var matchCat = !activeCategory || cat === activeCategory;
|
|
if (matchSearch && 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" : "";
|
|
}
|
|
|
|
search.addEventListener("input", filter);
|
|
|
|
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>
|