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.
716 lines
29 KiB
Plaintext
716 lines
29 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 { t } from "@/i18n";
|
|
import { localizeHref } from "@/lib/i18n-page";
|
|
import { loadToolStrings } from "@/lib/tool-strings";
|
|
import type { ModalityFilter, ToolSearchItem } from "../lib/tool-search";
|
|
import SectionHeading from "./SectionHeading.astro";
|
|
|
|
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 categoryMap = new Map(CATEGORIES.map((c) => [c.id, c]));
|
|
|
|
type ToolModality = ToolSearchItem["modality"];
|
|
|
|
const modalities: { id: ModalityFilter; label: string; count: number }[] = [
|
|
{ id: "all", label: t(locale, "home.toolGrid.filter.all"), count: TOOLS.length },
|
|
{
|
|
id: "image",
|
|
label: t(locale, "home.toolGrid.filter.image"),
|
|
count: TOOLS.filter((tool) => tool.modality === "image").length,
|
|
},
|
|
{
|
|
id: "video",
|
|
label: t(locale, "home.toolGrid.filter.video"),
|
|
count: TOOLS.filter((tool) => tool.modality === "video").length,
|
|
},
|
|
{
|
|
id: "audio",
|
|
label: t(locale, "home.toolGrid.filter.audio"),
|
|
count: TOOLS.filter((tool) => tool.modality === "audio").length,
|
|
},
|
|
{
|
|
id: "document,file",
|
|
label: t(locale, "home.toolGrid.filter.pdfFiles"),
|
|
count: TOOLS.filter((tool) => tool.modality === "document" || tool.modality === "file").length,
|
|
},
|
|
];
|
|
|
|
const workflows = [
|
|
{
|
|
id: "optimize",
|
|
label: t(locale, "home.toolGrid.workflow.optimize.label"),
|
|
description: t(locale, "home.toolGrid.workflow.optimize.description"),
|
|
query: "optimize files for web",
|
|
icon: "Gauge",
|
|
metric: t(locale, "home.toolGrid.workflow.optimize.metric"),
|
|
},
|
|
{
|
|
id: "convert",
|
|
label: t(locale, "home.toolGrid.workflow.convert.label"),
|
|
description: t(locale, "home.toolGrid.workflow.convert.description"),
|
|
query: "convert files to another format",
|
|
icon: "Repeat2",
|
|
metric: t(locale, "home.toolGrid.workflow.convert.metric"),
|
|
},
|
|
{
|
|
id: "pdf",
|
|
label: t(locale, "home.toolGrid.workflow.pdf.label"),
|
|
description: t(locale, "home.toolGrid.workflow.pdf.description"),
|
|
query: "prepare pdf documents",
|
|
icon: "FileStack",
|
|
metric: t(locale, "home.toolGrid.workflow.pdf.metric"),
|
|
},
|
|
{
|
|
id: "metadata",
|
|
label: t(locale, "home.toolGrid.workflow.metadata.label"),
|
|
description: t(locale, "home.toolGrid.workflow.metadata.description"),
|
|
query: "remove metadata",
|
|
icon: "ShieldCheck",
|
|
metric: t(locale, "home.toolGrid.workflow.metadata.metric"),
|
|
},
|
|
{
|
|
id: "batch",
|
|
label: t(locale, "home.toolGrid.workflow.batch.label"),
|
|
description: t(locale, "home.toolGrid.workflow.batch.description"),
|
|
query: "batch file operations",
|
|
icon: "ListChecks",
|
|
metric: t(locale, "home.toolGrid.workflow.batch.metric"),
|
|
},
|
|
{
|
|
id: "ai",
|
|
label: t(locale, "home.toolGrid.workflow.ai.label"),
|
|
description: t(locale, "home.toolGrid.workflow.ai.description"),
|
|
query: "ai enhance extract text remove background",
|
|
icon: "Sparkles",
|
|
metric: t(locale, "home.toolGrid.workflow.ai.metric"),
|
|
},
|
|
];
|
|
|
|
// User-facing strings the client search script renders dynamically. Passed via an
|
|
// inlined JSON blob so the script localizes without any runtime fetch.
|
|
const toolGridStrings = {
|
|
suggested: t(locale, "home.toolGrid.suggested"),
|
|
curatedTools: t(locale, "home.toolGrid.curatedTools", { count: "{count}" }),
|
|
startingPoints: t(locale, "home.toolGrid.startingPoints", { modality: "{modality}" }),
|
|
countOfTools: t(locale, "home.toolGrid.countOfTools", { count: "{count}", total: "{total}" }),
|
|
recommended: t(locale, "home.toolGrid.recommended"),
|
|
countOfMatches: t(locale, "home.toolGrid.countOfMatches", { count: "{count}", total: "{total}" }),
|
|
requestOrRefine: t(locale, "home.toolGrid.requestOrRefine"),
|
|
closeToolsOne: t(locale, "home.toolGrid.closeToolsOne", { count: "{count}" }),
|
|
closeToolsOther: t(locale, "home.toolGrid.closeToolsOther", { count: "{count}" }),
|
|
noConfidentMatch: t(locale, "home.toolGrid.noConfidentMatch"),
|
|
};
|
|
|
|
const workflowAliasesByToolId: Record<string, string[]> = {
|
|
"bulk-rename": ["batch file operations", "rename many files"],
|
|
"compress-pdf": ["prepare pdf documents", "optimize delivery"],
|
|
"compress-video": ["optimize delivery", "compress media"],
|
|
"convert-audio": ["convert formats", "audio converter"],
|
|
"convert-document": ["convert formats", "prepare documents"],
|
|
"convert-video": ["convert formats", "video converter"],
|
|
"create-zip": ["batch file operations", "archive files"],
|
|
"extract-zip": ["batch file operations", "extract archive"],
|
|
"image-to-pdf": ["prepare documents", "convert images to pdf"],
|
|
"merge-pdf": ["prepare pdf documents", "combine pdf"],
|
|
"optimize-for-web": ["optimize delivery", "web delivery"],
|
|
"pdfa-convert": ["prepare pdf documents", "archive pdf"],
|
|
"redact-pdf": ["prepare pdf documents", "hide sensitive text"],
|
|
"remove-background": ["enhance with ai", "transparent background"],
|
|
"remove-metadata": ["clean metadata", "strip exif"],
|
|
"split-pdf": ["prepare pdf documents", "extract pages"],
|
|
"transcribe-audio": ["enhance with ai", "speech to text"],
|
|
"watermark-pdf": ["prepare pdf documents", "add watermark"],
|
|
"word-to-pdf": ["prepare documents", "convert document to pdf"],
|
|
};
|
|
|
|
const defaultToolIds = [
|
|
"resize",
|
|
"compress",
|
|
"convert",
|
|
"remove-background",
|
|
"optimize-for-web",
|
|
"ocr",
|
|
"merge-pdf",
|
|
"split-pdf",
|
|
"compress-pdf",
|
|
"compress-video",
|
|
"convert-audio",
|
|
"extract-zip",
|
|
];
|
|
|
|
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("");
|
|
}
|
|
|
|
function toToolSearchItem(tool: (typeof TOOLS)[number]): ToolSearchItem {
|
|
const category = categoryMap.get(tool.category);
|
|
return {
|
|
id: tool.id,
|
|
name: toolStrings[tool.id]?.name ?? tool.name,
|
|
description: toolStrings[tool.id]?.description ?? tool.description,
|
|
modality: tool.modality as ToolModality,
|
|
category: tool.category,
|
|
url: localizeHref(locale, `/tools/${toolSection(tool)}/${tool.id}/`),
|
|
icon: tool.icon,
|
|
iconSvg: renderIcon(tool.icon),
|
|
color: category?.color ?? "#E07832",
|
|
acceptedInputs: [...tool.acceptedInputs],
|
|
outputModality: tool.outputModality as ToolModality | undefined,
|
|
keywords: [...(tool.keywords ?? [])],
|
|
workflowAliases: workflowAliasesByToolId[tool.id] ?? [],
|
|
};
|
|
}
|
|
|
|
const searchIndex = TOOLS.map(toToolSearchItem);
|
|
const defaultTools = defaultToolIds
|
|
.map((id) => searchIndex.find((tool) => tool.id === id))
|
|
.filter((tool): tool is ToolSearchItem => Boolean(tool));
|
|
|
|
const safeSearchIndexJson = JSON.stringify(searchIndex).replace(/</g, "\\u003c");
|
|
const safeDefaultToolIdsJson = JSON.stringify(defaultToolIds).replace(/</g, "\\u003c");
|
|
const safeStringsJson = JSON.stringify(toolGridStrings).replace(/</g, "\\u003c");
|
|
---
|
|
|
|
<section class="bg-background-alt px-6 py-20 md:py-28" id="features">
|
|
<SectionHeading
|
|
title={t(locale, "home.toolGrid.title")}
|
|
subtitle={t(locale, "home.toolGrid.subtitle")}
|
|
/>
|
|
|
|
<div class="mx-auto max-w-6xl">
|
|
<div class="overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
|
|
<div class="border-b border-border bg-background px-4 py-4 sm:px-5">
|
|
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
|
<div class="relative">
|
|
<label class="sr-only" for="tool-command-search">{t(locale, "home.toolGrid.searchLabel")}</label>
|
|
<svg
|
|
class="pointer-events-none absolute left-4 top-1/2 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"
|
|
aria-hidden="true"
|
|
set:html={renderIcon("Search")}
|
|
/>
|
|
<input
|
|
id="tool-command-search"
|
|
type="text"
|
|
inputmode="search"
|
|
autocomplete="off"
|
|
aria-label={t(locale, "home.toolGrid.searchLabel")}
|
|
placeholder={t(locale, "home.toolGrid.searchPlaceholder")}
|
|
class="h-13 w-full rounded-lg border border-border bg-surface py-3 ps-12 pe-12 text-base text-foreground outline-none transition-colors placeholder:text-muted focus:border-primary"
|
|
/>
|
|
<button
|
|
id="tool-command-clear"
|
|
type="button"
|
|
aria-label={t(locale, "home.toolGrid.clearLabel")}
|
|
class="absolute right-3 top-1/2 hidden h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted transition-colors hover:bg-background-alt hover:text-foreground"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
set:html={renderIcon("X")}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-wrap items-center gap-1 rounded-lg border border-border bg-surface p-1"
|
|
aria-label={t(locale, "home.toolGrid.filterLabel")}
|
|
>
|
|
{modalities.map((modality) => (
|
|
<button
|
|
type="button"
|
|
data-modality={modality.id}
|
|
aria-pressed={modality.id === "all" ? "true" : "false"}
|
|
class:list={[
|
|
"tool-command-modality rounded-md px-3 py-2 text-sm font-semibold transition-colors",
|
|
modality.id === "all"
|
|
? "bg-primary text-white shadow-sm"
|
|
: "text-muted hover:bg-background-alt hover:text-foreground",
|
|
]}
|
|
>
|
|
<span>{modality.label}</span>
|
|
<span class="ms-1 text-xs opacity-75">{modality.count}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid border-b border-border lg:grid-cols-[260px_minmax(0,1fr)]">
|
|
<aside class="order-2 border-t border-border bg-background-alt/50 p-4 lg:order-1 lg:border-e lg:border-t-0">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{t(locale, "home.toolGrid.workflowsHeading")}</p>
|
|
<button
|
|
id="tool-command-reset-workflow"
|
|
type="button"
|
|
class="hidden rounded-md px-2 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary-subtle"
|
|
>
|
|
{t(locale, "home.toolGrid.reset")}
|
|
</button>
|
|
</div>
|
|
<div class="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
|
{workflows.map((workflow) => (
|
|
<button
|
|
type="button"
|
|
data-workflow={workflow.query}
|
|
data-workflow-id={workflow.id}
|
|
data-label={workflow.label}
|
|
aria-pressed="false"
|
|
class="tool-command-workflow group rounded-lg border border-border bg-surface p-3 text-start transition-all hover:border-primary hover:bg-primary-subtle/40"
|
|
>
|
|
<span class="flex items-center gap-3">
|
|
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-background-alt text-primary transition-colors group-hover:bg-surface">
|
|
<svg
|
|
class="h-4.5 w-4.5"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
set:html={renderIcon(workflow.icon)}
|
|
/>
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block text-sm font-semibold text-foreground">{workflow.label}</span>
|
|
<span class="mt-0.5 block text-xs font-medium text-primary-dark">{workflow.metric}</span>
|
|
</span>
|
|
</span>
|
|
<span class="mt-2 block text-xs leading-relaxed text-muted">{workflow.description}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
<div class="order-1 min-w-0 p-4 sm:p-5 lg:order-2">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{t(locale, "home.toolGrid.finderLabel")}</p>
|
|
<h3 class="mt-1 text-xl font-bold tracking-tight text-foreground" id="tool-command-title">
|
|
{t(locale, "home.toolGrid.suggested")}
|
|
</h3>
|
|
</div>
|
|
<p class="text-sm text-muted" id="tool-command-count" aria-live="polite">
|
|
{t(locale, "home.toolGrid.curatedTools", { count: defaultTools.length })}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
|
id="tool-command-results"
|
|
>
|
|
{defaultTools.map((tool) => (
|
|
<a
|
|
href={tool.url}
|
|
class="tool-card group flex min-h-[148px] flex-col rounded-xl border border-border bg-surface p-4 no-underline transition-all"
|
|
style={`--cat-color:${tool.color};`}
|
|
>
|
|
<span class="flex items-start justify-between gap-3">
|
|
<span
|
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background-alt"
|
|
style={`color:${tool.color};`}
|
|
>
|
|
<svg
|
|
class="h-5 w-5"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
set:html={tool.iconSvg}
|
|
/>
|
|
</span>
|
|
<span class="rounded-full bg-background-alt px-2 py-1 text-[11px] font-semibold uppercase text-muted">
|
|
{tool.modality === "document" ? "PDF" : tool.modality}
|
|
</span>
|
|
</span>
|
|
<span class="mt-4 text-sm font-semibold text-foreground">{tool.name}</span>
|
|
<span class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted">{tool.description}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
|
|
<div
|
|
class="mt-5 hidden rounded-xl border border-dashed border-border bg-background-alt p-5"
|
|
id="tool-command-empty"
|
|
>
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-bold tracking-tight text-foreground">{t(locale, "home.toolGrid.empty.title")}</h3>
|
|
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-muted">
|
|
{t(locale, "home.toolGrid.empty.body")}
|
|
</p>
|
|
</div>
|
|
<a
|
|
href="https://github.com/snapotter-hq/snapotter/discussions/new?category=ideas&title=Tool+request"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
id="tool-command-request-empty"
|
|
class="btn-primary inline-flex shrink-0 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold"
|
|
>
|
|
{t(locale, "home.toolGrid.empty.request")}
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
set:html={renderIcon("ArrowUpRight")}
|
|
/>
|
|
</a>
|
|
</div>
|
|
<div class="mt-5 hidden">
|
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-muted">{t(locale, "home.toolGrid.closest")}</p>
|
|
<div class="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3" id="tool-command-related"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-3 bg-background px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
|
<div class="text-sm text-muted">
|
|
{t(locale, "home.toolGrid.missingWorkflow")}
|
|
<a
|
|
href="https://github.com/snapotter-hq/snapotter/discussions/new?category=ideas&title=Tool+request"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
id="tool-command-request"
|
|
class="font-semibold text-primary hover:underline"
|
|
>
|
|
{t(locale, "home.toolGrid.requestTool")}
|
|
</a>
|
|
{t(locale, "home.toolGrid.requestSuffix")}
|
|
</div>
|
|
<a href={localizeHref(locale, "/tools")} class="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline">
|
|
{t(locale, "home.toolGrid.browseCatalog")}
|
|
<svg
|
|
class="h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
set:html={renderIcon("ArrowRight")}
|
|
/>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script id="tool-command-index" type="application/json" is:inline set:html={safeSearchIndexJson}></script>
|
|
<script id="tool-command-default-ids" type="application/json" is:inline set:html={safeDefaultToolIdsJson}></script>
|
|
<script id="tool-command-strings" type="application/json" is:inline set:html={safeStringsJson}></script>
|
|
</section>
|
|
|
|
<script>
|
|
import {
|
|
buildToolRequestDiscussionUrl,
|
|
searchTools,
|
|
type ModalityFilter,
|
|
type ToolSearchItem,
|
|
type ToolSearchResult,
|
|
} from "../lib/tool-search";
|
|
|
|
const indexEl = document.getElementById("tool-command-index");
|
|
const defaultIdsEl = document.getElementById("tool-command-default-ids");
|
|
const stringsEl = document.getElementById("tool-command-strings");
|
|
const searchInput = document.getElementById("tool-command-search") as HTMLInputElement | null;
|
|
const clearButton = document.getElementById("tool-command-clear") as HTMLButtonElement | null;
|
|
const resetWorkflowButton = document.getElementById(
|
|
"tool-command-reset-workflow",
|
|
) as HTMLButtonElement | null;
|
|
const resultsEl = document.getElementById("tool-command-results");
|
|
const emptyEl = document.getElementById("tool-command-empty");
|
|
const relatedEl = document.getElementById("tool-command-related");
|
|
const titleEl = document.getElementById("tool-command-title");
|
|
const countEl = document.getElementById("tool-command-count");
|
|
const requestLink = document.getElementById("tool-command-request") as HTMLAnchorElement | null;
|
|
const requestEmptyLink = document.getElementById(
|
|
"tool-command-request-empty",
|
|
) as HTMLAnchorElement | null;
|
|
const modalityButtons = [...document.querySelectorAll<HTMLButtonElement>(".tool-command-modality")];
|
|
const workflowButtons = [...document.querySelectorAll<HTMLButtonElement>(".tool-command-workflow")];
|
|
|
|
if (
|
|
indexEl?.textContent &&
|
|
defaultIdsEl?.textContent &&
|
|
stringsEl?.textContent &&
|
|
searchInput &&
|
|
clearButton &&
|
|
resetWorkflowButton &&
|
|
resultsEl &&
|
|
emptyEl &&
|
|
relatedEl &&
|
|
titleEl &&
|
|
countEl
|
|
) {
|
|
const searchField = searchInput;
|
|
const clearSearchButton = clearButton;
|
|
const resetButton = resetWorkflowButton;
|
|
const resultContainer = resultsEl;
|
|
const emptyContainer = emptyEl;
|
|
const relatedContainer = relatedEl;
|
|
const titleNode = titleEl;
|
|
const countNode = countEl;
|
|
const tools = JSON.parse(indexEl.textContent) as ToolSearchItem[];
|
|
const defaultToolIds = JSON.parse(defaultIdsEl.textContent) as string[];
|
|
const defaultToolIdSet = new Set(defaultToolIds);
|
|
const strings = JSON.parse(stringsEl.textContent) as Record<string, string>;
|
|
const fill = (template: string, vars: Record<string, string | number>): string =>
|
|
template.replace(/\{(\w+)\}/g, (m, name) =>
|
|
name in vars ? String(vars[name]) : m,
|
|
);
|
|
let activeModality: ModalityFilter = "all";
|
|
let activeWorkflowQuery = "";
|
|
let activeWorkflowLabel = "";
|
|
|
|
const modalityLabels = new Map(
|
|
modalityButtons.map((button) => [
|
|
button.dataset.modality as ModalityFilter,
|
|
button.querySelector("span")?.textContent?.trim() ?? "All",
|
|
]),
|
|
);
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function formatReason(reason: string): string {
|
|
return reason
|
|
.replace("match", "")
|
|
.replace("token", "")
|
|
.replace("phrase", "")
|
|
.trim();
|
|
}
|
|
|
|
function matchesActiveModality(tool: ToolSearchItem): boolean {
|
|
if (activeModality === "all") return true;
|
|
if (activeModality === "document,file") {
|
|
return tool.modality === "document" || tool.modality === "file";
|
|
}
|
|
return tool.modality === activeModality;
|
|
}
|
|
|
|
function modalityLabel(tool: ToolSearchItem): string {
|
|
if (tool.modality === "document") return "PDF";
|
|
return tool.modality;
|
|
}
|
|
|
|
function renderCard(resultOrItem: ToolSearchItem | ToolSearchResult, showReason = false): string {
|
|
const result =
|
|
"item" in resultOrItem ? resultOrItem : ({ item: resultOrItem, reason: "" } as ToolSearchResult);
|
|
const { item } = result;
|
|
const reason = formatReason(result.reason);
|
|
const reasonHtml =
|
|
showReason && reason
|
|
? `<span class="mt-3 inline-flex w-fit rounded-md bg-primary-subtle px-2 py-1 text-[11px] font-semibold text-primary-dark">${escapeHtml(reason)}</span>`
|
|
: "";
|
|
|
|
return `
|
|
<a href="${escapeHtml(item.url)}" class="tool-card group flex min-h-[148px] flex-col rounded-xl border border-border bg-surface p-4 no-underline transition-all" style="--cat-color:${escapeHtml(item.color)};">
|
|
<span class="flex items-start justify-between gap-3">
|
|
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background-alt" style="color:${escapeHtml(item.color)};">
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${item.iconSvg}</svg>
|
|
</span>
|
|
<span class="rounded-full bg-background-alt px-2 py-1 text-[11px] font-semibold uppercase text-muted">${escapeHtml(modalityLabel(item))}</span>
|
|
</span>
|
|
<span class="mt-4 text-sm font-semibold text-foreground">${escapeHtml(item.name)}</span>
|
|
<span class="mt-1 line-clamp-2 text-xs leading-relaxed text-muted">${escapeHtml(item.description)}</span>
|
|
${reasonHtml}
|
|
</a>
|
|
`;
|
|
}
|
|
|
|
function setRequestLinks(query: string): void {
|
|
const href = buildToolRequestDiscussionUrl(query);
|
|
if (requestLink) requestLink.href = href;
|
|
if (requestEmptyLink) requestEmptyLink.href = href;
|
|
}
|
|
|
|
function updateClearButton(hasQuery: boolean): void {
|
|
clearSearchButton.classList.toggle("hidden", !hasQuery);
|
|
clearSearchButton.classList.toggle("inline-flex", hasQuery);
|
|
}
|
|
|
|
function setActiveModality(nextModality: ModalityFilter): void {
|
|
activeModality = nextModality;
|
|
for (const button of modalityButtons) {
|
|
const isActive = button.dataset.modality === activeModality;
|
|
button.setAttribute("aria-pressed", String(isActive));
|
|
button.classList.toggle("bg-primary", isActive);
|
|
button.classList.toggle("text-white", isActive);
|
|
button.classList.toggle("shadow-sm", isActive);
|
|
button.classList.toggle("text-muted", !isActive);
|
|
button.classList.toggle("hover:bg-background-alt", !isActive);
|
|
button.classList.toggle("hover:text-foreground", !isActive);
|
|
}
|
|
}
|
|
|
|
function setActiveWorkflow(query: string, label: string): void {
|
|
activeWorkflowQuery = query;
|
|
activeWorkflowLabel = label;
|
|
for (const button of workflowButtons) {
|
|
const isActive = button.dataset.workflow === activeWorkflowQuery;
|
|
button.setAttribute("aria-pressed", String(isActive));
|
|
button.classList.toggle("border-primary", isActive);
|
|
button.classList.toggle("bg-primary-subtle", isActive);
|
|
button.classList.toggle("border-border", !isActive);
|
|
button.classList.toggle("bg-surface", !isActive);
|
|
}
|
|
resetButton.classList.toggle("hidden", !activeWorkflowQuery);
|
|
}
|
|
|
|
function searchQuery(): string {
|
|
return searchField.value.trim() || activeWorkflowQuery;
|
|
}
|
|
|
|
function hideEmptyState(): void {
|
|
emptyContainer.classList.add("hidden");
|
|
relatedContainer.innerHTML = "";
|
|
relatedContainer.parentElement?.classList.add("hidden");
|
|
}
|
|
|
|
function renderDefault(): void {
|
|
const matchingTools = tools.filter(matchesActiveModality);
|
|
const defaultTools =
|
|
activeModality === "all"
|
|
? tools.filter((tool) => defaultToolIdSet.has(tool.id))
|
|
: matchingTools.slice(0, 12);
|
|
const modalityName = modalityLabels.get(activeModality) ?? "All";
|
|
|
|
titleNode.textContent =
|
|
activeModality === "all"
|
|
? strings.suggested
|
|
: fill(strings.startingPoints, { modality: modalityName });
|
|
countNode.textContent =
|
|
activeModality === "all"
|
|
? fill(strings.curatedTools, { count: defaultTools.length })
|
|
: fill(strings.countOfTools, { count: defaultTools.length, total: matchingTools.length });
|
|
resultContainer.innerHTML = defaultTools.map((tool) => renderCard(tool)).join("");
|
|
resultContainer.classList.remove("hidden");
|
|
hideEmptyState();
|
|
setRequestLinks("");
|
|
updateClearButton(false);
|
|
}
|
|
|
|
function renderSearch(): void {
|
|
const query = searchQuery();
|
|
const hasQuery = query.length > 0;
|
|
updateClearButton(hasQuery);
|
|
setRequestLinks(query);
|
|
|
|
if (!hasQuery) {
|
|
renderDefault();
|
|
return;
|
|
}
|
|
|
|
const response = searchTools(tools, { query, modality: activeModality, limit: 12 });
|
|
|
|
if (response.hasConfidentMatch && response.results.length > 0) {
|
|
titleNode.textContent = activeWorkflowLabel || strings.recommended;
|
|
countNode.textContent = fill(strings.countOfMatches, {
|
|
count: response.results.length,
|
|
total: response.totalMatches,
|
|
});
|
|
resultContainer.innerHTML = response.results.map((result) => renderCard(result, true)).join("");
|
|
resultContainer.classList.remove("hidden");
|
|
hideEmptyState();
|
|
return;
|
|
}
|
|
|
|
titleNode.textContent = strings.requestOrRefine;
|
|
countNode.textContent =
|
|
response.related.length > 0
|
|
? fill(response.related.length === 1 ? strings.closeToolsOne : strings.closeToolsOther, {
|
|
count: response.related.length,
|
|
})
|
|
: strings.noConfidentMatch;
|
|
resultContainer.classList.add("hidden");
|
|
emptyContainer.classList.remove("hidden");
|
|
relatedContainer.innerHTML = response.related.map((result) => renderCard(result, true)).join("");
|
|
relatedContainer.parentElement?.classList.toggle("hidden", response.related.length === 0);
|
|
}
|
|
|
|
searchField.addEventListener("input", () => {
|
|
if (searchField.value.trim() !== activeWorkflowQuery) {
|
|
setActiveWorkflow("", "");
|
|
}
|
|
renderSearch();
|
|
});
|
|
|
|
clearSearchButton.addEventListener("click", () => {
|
|
searchField.value = "";
|
|
setActiveWorkflow("", "");
|
|
searchField.focus();
|
|
renderDefault();
|
|
});
|
|
|
|
resetButton.addEventListener("click", () => {
|
|
searchField.value = "";
|
|
setActiveWorkflow("", "");
|
|
renderDefault();
|
|
});
|
|
|
|
for (const button of modalityButtons) {
|
|
button.addEventListener("click", () => {
|
|
setActiveModality((button.dataset.modality as ModalityFilter) ?? "all");
|
|
renderSearch();
|
|
});
|
|
}
|
|
|
|
for (const button of workflowButtons) {
|
|
button.addEventListener("click", () => {
|
|
const query = button.dataset.workflow ?? "";
|
|
const label = button.dataset.label ?? "";
|
|
searchField.value = "";
|
|
setActiveWorkflow(activeWorkflowQuery === query ? "" : query, activeWorkflowQuery === query ? "" : label);
|
|
renderSearch();
|
|
});
|
|
}
|
|
|
|
renderDefault();
|
|
}
|
|
</script>
|