feat(web): group home grid, tabs, and breadcrumb by section

This commit is contained in:
SnapOtter
2026-06-20 12:51:41 +08:00
parent 22b4b5c3c1
commit 8301676e13
26 changed files with 113 additions and 74 deletions
+1 -1
View File
@@ -240,7 +240,7 @@ export function App() {
<Route path="/privacy" element={<PrivacyPolicyPage />} /> <Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} /> <Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route path="/editor" element={<EditorPage />} /> <Route path="/editor" element={<EditorPage />} />
<Route path="/:modality/:toolId" element={<ToolPage />} /> <Route path="/:section/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
+12 -11
View File
@@ -1,5 +1,5 @@
import type { Tool } from "@snapotter/shared"; import type { Tool } from "@snapotter/shared";
import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP } from "@snapotter/shared"; import { PYTHON_SIDECAR_TOOLS, SECTIONS, TOOL_BUNDLE_MAP, toolSection } from "@snapotter/shared";
import { Clock, Download, FileImage, Loader2 } from "lucide-react"; import { Clock, Download, FileImage, Loader2 } from "lucide-react";
import { useMemo } from "react"; import { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -15,8 +15,8 @@ interface ToolCardProps {
showModalityBadge?: boolean; showModalityBadge?: boolean;
} }
const MODALITY_COLOR_MAP: Record<string, string> = Object.fromEntries( const SECTION_COLOR_MAP: Record<string, string> = Object.fromEntries(
MODALITIES.map((m) => [m.id, m.color]), SECTIONS.map((s) => [s.id, s.color]),
); );
export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolCardProps) { export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolCardProps) {
@@ -38,17 +38,18 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
return bundle?.status === "installed" ? "installed" : "not_installed"; return bundle?.status === "installed" ? "installed" : "not_installed";
}, [isAiTool, tool.id, bundles, installing, queued]); }, [isAiTool, tool.id, bundles, installing, queued]);
const modalityColor = MODALITY_COLOR_MAP[tool.modality] ?? "#6B7280"; const section = toolSection(tool);
const sectionColor = SECTION_COLOR_MAP[section] ?? "#6B7280";
const modalityBadge = showModalityBadge ? ( const sectionBadge = showModalityBadge ? (
<span <span
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0" className="text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0"
style={{ style={{
backgroundColor: `${modalityColor}20`, backgroundColor: `${sectionColor}20`,
color: modalityColor, color: sectionColor,
}} }}
> >
{MODALITIES.find((m) => m.id === tool.modality)?.name ?? tool.modality} {SECTIONS.find((s) => s.id === section)?.name ?? section}
</span> </span>
) : null; ) : null;
@@ -76,7 +77,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
> >
<div <div
className="p-2 rounded-lg shrink-0 mt-0.5" className="p-2 rounded-lg shrink-0 mt-0.5"
style={{ backgroundColor: `${modalityColor}12`, color: modalityColor }} style={{ backgroundColor: `${sectionColor}12`, color: sectionColor }}
> >
<IconComponent className="h-5 w-5" /> <IconComponent className="h-5 w-5" />
</div> </div>
@@ -85,7 +86,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)} {getToolName(t, tool.id, tool.name)}
</span> </span>
{modalityBadge} {sectionBadge}
{tool.experimental && ( {tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
{t.common.experimental} {t.common.experimental}
@@ -114,7 +115,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)} {getToolName(t, tool.id, tool.name)}
</span> </span>
{modalityBadge} {sectionBadge}
{tool.experimental && ( {tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
{t.common.experimental} {t.common.experimental}
+2 -2
View File
@@ -89,7 +89,7 @@ export function TopNav({
{breadcrumb.modality && ( {breadcrumb.modality && (
<span className={isDark ? "text-[#aaa]" : "text-muted-foreground"}> <span className={isDark ? "text-[#aaa]" : "text-muted-foreground"}>
{breadcrumb.modalityTab ? ( {breadcrumb.modalityTab ? (
<Link to={`/?modality=${breadcrumb.modalityTab}`} className="hover:underline"> <Link to={`/?section=${breadcrumb.modalityTab}`} className="hover:underline">
{breadcrumb.modality} {breadcrumb.modality}
</Link> </Link>
) : ( ) : (
@@ -163,7 +163,7 @@ export function TopNav({
/> />
{breadcrumb.modalityTab ? ( {breadcrumb.modalityTab ? (
<Link <Link
to={`/?modality=${breadcrumb.modalityTab}`} to={`/?section=${breadcrumb.modalityTab}`}
className={cn( className={cn(
"shrink-0 hover:underline", "shrink-0 hover:underline",
isDark isDark
+45 -55
View File
@@ -1,5 +1,5 @@
import type { Tool } from "@snapotter/shared"; import type { Tool } from "@snapotter/shared";
import { CATEGORIES, MODALITIES, TOOLS } from "@snapotter/shared"; import { CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
import { ChevronDown, Search, X } from "lucide-react"; import { ChevronDown, Search, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -20,9 +20,9 @@ interface TabDef {
label: string; label: string;
} }
const COLLAPSE_STORAGE_KEY = "snapotter-collapsed-modalities"; const COLLAPSE_STORAGE_KEY = "snapotter-collapsed-sections";
function getCollapsedModalities(): Set<string> { function getCollapsedSections(): Set<string> {
try { try {
const raw = localStorage.getItem(COLLAPSE_STORAGE_KEY); const raw = localStorage.getItem(COLLAPSE_STORAGE_KEY);
return raw ? new Set(JSON.parse(raw)) : new Set(); return raw ? new Set(JSON.parse(raw)) : new Set();
@@ -31,19 +31,11 @@ function getCollapsedModalities(): Set<string> {
} }
} }
function saveCollapsedModalities(collapsed: Set<string>) { function saveCollapsedSections(collapsed: Set<string>) {
localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsed])); localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsed]));
} }
const MODALITY_TAB_ORDER = [ const SECTION_TABS = new Set<string>(["all", ...SECTIONS.map((s) => s.id)]);
{ modalityId: "image", tabKey: "image", label: "Image" },
{ modalityId: "video", tabKey: "video", label: "Video" },
{ modalityId: "audio", tabKey: "audio", label: "Audio" },
{ modalityId: "document", tabKey: "document", label: "PDF" },
{ modalityId: "file", tabKey: "data", label: "Data" },
];
const MODALITY_TABS = new Set<string>(["all", ...MODALITY_TAB_ORDER.map((m) => m.tabKey)]);
export function HomePage() { export function HomePage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -60,12 +52,12 @@ export function HomePage() {
fetchSettings(); fetchSettings();
}, [fetchSettings]); }, [fetchSettings]);
// Open a specific modality tab when arriving via a breadcrumb link // Open a specific section tab when arriving via a breadcrumb link
// (/?modality=<tabKey>), then clean the URL so refresh/back doesn't re-pin it. // (/?section=<sectionId>), then clean the URL so refresh/back doesn't re-pin it.
useEffect(() => { useEffect(() => {
const m = new URLSearchParams(location.search).get("modality"); const s = new URLSearchParams(location.search).get("section");
if (m && MODALITY_TABS.has(m)) { if (s && SECTION_TABS.has(s)) {
setActiveTab(m); setActiveTab(s);
navigate("/", { replace: true }); navigate("/", { replace: true });
} }
}, [location.search, navigate]); }, [location.search, navigate]);
@@ -73,11 +65,10 @@ export function HomePage() {
const tabs: TabDef[] = useMemo( const tabs: TabDef[] = useMemo(
() => [ () => [
{ key: "all", label: t.homePage.all }, { key: "all", label: t.homePage.all },
{ key: "image", label: "Image" }, ...SECTIONS.map((s) => ({
{ key: "video", label: "Video" }, key: s.id,
{ key: "audio", label: "Audio" }, label: s.id === "pdf" ? t.homePage.pdf : s.id === "files" ? t.homePage.files : s.name,
{ key: "document", label: t.homePage.documents }, })),
{ key: "data", label: t.homePage.data },
], ],
[t], [t],
); );
@@ -96,8 +87,7 @@ export function HomePage() {
const tabTools = useMemo(() => { const tabTools = useMemo(() => {
if (activeTab === "all") return visibleTools; if (activeTab === "all") return visibleTools;
const modalityKey = activeTab === "data" ? "file" : activeTab; return visibleTools.filter((tool) => toolSection(tool) === activeTab);
return visibleTools.filter((tool) => tool.modality === modalityKey);
}, [visibleTools, activeTab]); }, [visibleTools, activeTab]);
const groupedTools = useMemo(() => { const groupedTools = useMemo(() => {
@@ -116,8 +106,8 @@ export function HomePage() {
const tabCounts = useMemo(() => { const tabCounts = useMemo(() => {
const counts: Record<string, number> = { all: visibleTools.length }; const counts: Record<string, number> = { all: visibleTools.length };
for (const tool of visibleTools) { for (const tool of visibleTools) {
const tabKey = tool.modality === "file" ? "data" : tool.modality; const sec = toolSection(tool);
counts[tabKey] = (counts[tabKey] ?? 0) + 1; counts[sec] = (counts[sec] ?? 0) + 1;
} }
return counts; return counts;
}, [visibleTools]); }, [visibleTools]);
@@ -296,7 +286,7 @@ function SearchResults({
); );
} }
// ── All Tab: Grouped by modality, then by category ─────────────── // ── All Tab: Grouped by section, then by category ───────────────
function AllTabContent({ function AllTabContent({
recentTools, recentTools,
@@ -306,29 +296,30 @@ function AllTabContent({
visibleTools: Tool[]; visibleTools: Tool[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [collapsed, setCollapsed] = useState<Set<string>>(getCollapsedModalities); const [collapsed, setCollapsed] = useState<Set<string>>(getCollapsedSections);
const toggleModality = useCallback((modalityId: string) => { const toggleSection = useCallback((sectionId: string) => {
setCollapsed((prev) => { setCollapsed((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(modalityId)) next.delete(modalityId); if (next.has(sectionId)) next.delete(sectionId);
else next.add(modalityId); else next.add(sectionId);
saveCollapsedModalities(next); saveCollapsedSections(next);
return next; return next;
}); });
}, []); }, []);
const toolsByModality = useMemo(() => { const toolsBySection = useMemo(() => {
const map = new Map<string, Map<string, Tool[]>>(); const map = new Map<string, Map<string, Tool[]>>();
for (const tool of visibleTools) { for (const tool of visibleTools) {
let modMap = map.get(tool.modality); const sec = toolSection(tool);
if (!modMap) { let catMap = map.get(sec);
modMap = new Map(); if (!catMap) {
map.set(tool.modality, modMap); catMap = new Map();
map.set(sec, catMap);
} }
const existing = modMap.get(tool.category); const existing = catMap.get(tool.category);
if (existing) existing.push(tool); if (existing) existing.push(tool);
else modMap.set(tool.category, [tool]); else catMap.set(tool.category, [tool]);
} }
return map; return map;
}, [visibleTools]); }, [visibleTools]);
@@ -355,37 +346,36 @@ function AllTabContent({
</section> </section>
)} )}
{/* Modality sections */} {/* Section groups */}
{MODALITY_TAB_ORDER.map(({ modalityId, label }) => { {SECTIONS.map((sec) => {
const categoryMap = toolsByModality.get(modalityId); const categoryMap = toolsBySection.get(sec.id);
if (!categoryMap || categoryMap.size === 0) return null; if (!categoryMap || categoryMap.size === 0) return null;
const totalCount = [...categoryMap.values()].reduce((sum, arr) => sum + arr.length, 0); const totalCount = [...categoryMap.values()].reduce((sum, arr) => sum + arr.length, 0);
const isCollapsed = collapsed.has(modalityId); const isCollapsed = collapsed.has(sec.id);
const modality = MODALITIES.find((m) => m.id === modalityId); const SectionIcon = ICON_MAP[sec.icon] as
const ModalityIcon = modality | React.ComponentType<{ className?: string }>
? (ICON_MAP[modality.icon] as React.ComponentType<{ className?: string }>) | undefined;
: null;
return ( return (
<section key={modalityId}> <section key={sec.id}>
<button <button
type="button" type="button"
onClick={() => toggleModality(modalityId)} onClick={() => toggleSection(sec.id)}
className="w-full flex items-center gap-2 py-2 mb-2 border-b border-border/40 group cursor-pointer" className="w-full flex items-center gap-2 py-2 mb-2 border-b border-border/40 group cursor-pointer"
> >
{ModalityIcon && ( {SectionIcon && (
<div <div
className="p-1 rounded" className="p-1 rounded"
style={{ style={{
backgroundColor: `${modality?.color ?? "#6B7280"}15`, backgroundColor: `${sec.color}15`,
color: modality?.color ?? "#6B7280", color: sec.color,
}} }}
> >
<ModalityIcon className="h-4 w-4" /> <SectionIcon className="h-4 w-4" />
</div> </div>
)} )}
<span className="text-sm font-semibold text-foreground">{label}</span> <span className="text-sm font-semibold text-foreground">{sec.name}</span>
<span className="text-xs text-muted-foreground">{totalCount}</span> <span className="text-xs text-muted-foreground">{totalCount}</span>
<div className="flex-1" /> <div className="flex-1" />
<ChevronDown <ChevronDown
+11 -5
View File
@@ -1,4 +1,10 @@
import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared"; import {
PYTHON_SIDECAR_TOOLS,
SECTIONS,
TOOL_BUNDLE_MAP,
TOOLS,
toolSection,
} from "@snapotter/shared";
import { import {
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
@@ -233,11 +239,11 @@ export function ToolPage() {
const breadcrumb = useMemo(() => { const breadcrumb = useMemo(() => {
if (!tool) return undefined; if (!tool) return undefined;
const modalityInfo = MODALITIES.find((m) => m.id === tool.modality); const section = toolSection(tool);
const modalityDisplay = modalityInfo?.name ?? tool.modality; const sectionInfo = SECTIONS.find((s) => s.id === section);
return { return {
modality: modalityDisplay, modality: sectionInfo?.name ?? section,
modalityTab: tool.modality === "file" ? "data" : tool.modality, modalityTab: section,
toolName: getToolName(t, tool.id, tool.name), toolName: getToolName(t, tool.id, tool.name),
}; };
}, [tool, t]); }, [tool, t]);
+2
View File
@@ -2489,6 +2489,8 @@ export const ar: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2503,6 +2503,8 @@ export const de: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2453,6 +2453,8 @@ export const en = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2485,6 +2485,8 @@ export const es: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2504,6 +2504,8 @@ export const fr: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2486,6 +2486,8 @@ export const hi: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2498,6 +2498,8 @@ export const id: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2497,6 +2497,8 @@ export const it: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2456,6 +2456,8 @@ export const ja: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2441,6 +2441,8 @@ export const ko: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2500,6 +2500,8 @@ export const nl: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2501,6 +2501,8 @@ export const pl: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2497,6 +2497,8 @@ export const ptBR: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2499,6 +2499,8 @@ export const ru: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2496,6 +2496,8 @@ export const sv: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2478,6 +2478,8 @@ export const th: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2501,6 +2501,8 @@ export const tr: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2499,6 +2499,8 @@ export const uk: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2498,6 +2498,8 @@ export const vi: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2431,6 +2431,8 @@ export const zhCN: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },
+2
View File
@@ -2429,6 +2429,8 @@ export const zhTW: TranslationKeys = {
all: "All", all: "All",
documents: "PDF", documents: "PDF",
data: "Data", data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools", toolCount: "{count} tools",
heading: "SnapOtter Tools", heading: "SnapOtter Tools",
}, },