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="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/:modality/:toolId" element={<ToolPage />} />
<Route path="/:section/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
+12 -11
View File
@@ -1,5 +1,5 @@
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 { useMemo } from "react";
import { Link } from "react-router-dom";
@@ -15,8 +15,8 @@ interface ToolCardProps {
showModalityBadge?: boolean;
}
const MODALITY_COLOR_MAP: Record<string, string> = Object.fromEntries(
MODALITIES.map((m) => [m.id, m.color]),
const SECTION_COLOR_MAP: Record<string, string> = Object.fromEntries(
SECTIONS.map((s) => [s.id, s.color]),
);
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";
}, [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
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium shrink-0"
style={{
backgroundColor: `${modalityColor}20`,
color: modalityColor,
backgroundColor: `${sectionColor}20`,
color: sectionColor,
}}
>
{MODALITIES.find((m) => m.id === tool.modality)?.name ?? tool.modality}
{SECTIONS.find((s) => s.id === section)?.name ?? section}
</span>
) : null;
@@ -76,7 +77,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
>
<div
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" />
</div>
@@ -85,7 +86,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
<span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{modalityBadge}
{sectionBadge}
{tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
{t.common.experimental}
@@ -114,7 +115,7 @@ export function ToolCard({ tool, variant = "compact", showModalityBadge }: ToolC
<span className="text-sm font-medium text-foreground">
{getToolName(t, tool.id, tool.name)}
</span>
{modalityBadge}
{sectionBadge}
{tool.experimental && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
{t.common.experimental}
+2 -2
View File
@@ -89,7 +89,7 @@ export function TopNav({
{breadcrumb.modality && (
<span className={isDark ? "text-[#aaa]" : "text-muted-foreground"}>
{breadcrumb.modalityTab ? (
<Link to={`/?modality=${breadcrumb.modalityTab}`} className="hover:underline">
<Link to={`/?section=${breadcrumb.modalityTab}`} className="hover:underline">
{breadcrumb.modality}
</Link>
) : (
@@ -163,7 +163,7 @@ export function TopNav({
/>
{breadcrumb.modalityTab ? (
<Link
to={`/?modality=${breadcrumb.modalityTab}`}
to={`/?section=${breadcrumb.modalityTab}`}
className={cn(
"shrink-0 hover:underline",
isDark
+45 -55
View File
@@ -1,5 +1,5 @@
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
@@ -20,9 +20,9 @@ interface TabDef {
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 {
const raw = localStorage.getItem(COLLAPSE_STORAGE_KEY);
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]));
}
const MODALITY_TAB_ORDER = [
{ 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)]);
const SECTION_TABS = new Set<string>(["all", ...SECTIONS.map((s) => s.id)]);
export function HomePage() {
const { t } = useTranslation();
@@ -60,12 +52,12 @@ export function HomePage() {
fetchSettings();
}, [fetchSettings]);
// Open a specific modality tab when arriving via a breadcrumb link
// (/?modality=<tabKey>), then clean the URL so refresh/back doesn't re-pin it.
// Open a specific section tab when arriving via a breadcrumb link
// (/?section=<sectionId>), then clean the URL so refresh/back doesn't re-pin it.
useEffect(() => {
const m = new URLSearchParams(location.search).get("modality");
if (m && MODALITY_TABS.has(m)) {
setActiveTab(m);
const s = new URLSearchParams(location.search).get("section");
if (s && SECTION_TABS.has(s)) {
setActiveTab(s);
navigate("/", { replace: true });
}
}, [location.search, navigate]);
@@ -73,11 +65,10 @@ export function HomePage() {
const tabs: TabDef[] = useMemo(
() => [
{ key: "all", label: t.homePage.all },
{ key: "image", label: "Image" },
{ key: "video", label: "Video" },
{ key: "audio", label: "Audio" },
{ key: "document", label: t.homePage.documents },
{ key: "data", label: t.homePage.data },
...SECTIONS.map((s) => ({
key: s.id,
label: s.id === "pdf" ? t.homePage.pdf : s.id === "files" ? t.homePage.files : s.name,
})),
],
[t],
);
@@ -96,8 +87,7 @@ export function HomePage() {
const tabTools = useMemo(() => {
if (activeTab === "all") return visibleTools;
const modalityKey = activeTab === "data" ? "file" : activeTab;
return visibleTools.filter((tool) => tool.modality === modalityKey);
return visibleTools.filter((tool) => toolSection(tool) === activeTab);
}, [visibleTools, activeTab]);
const groupedTools = useMemo(() => {
@@ -116,8 +106,8 @@ export function HomePage() {
const tabCounts = useMemo(() => {
const counts: Record<string, number> = { all: visibleTools.length };
for (const tool of visibleTools) {
const tabKey = tool.modality === "file" ? "data" : tool.modality;
counts[tabKey] = (counts[tabKey] ?? 0) + 1;
const sec = toolSection(tool);
counts[sec] = (counts[sec] ?? 0) + 1;
}
return counts;
}, [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({
recentTools,
@@ -306,29 +296,30 @@ function AllTabContent({
visibleTools: Tool[];
}) {
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) => {
const next = new Set(prev);
if (next.has(modalityId)) next.delete(modalityId);
else next.add(modalityId);
saveCollapsedModalities(next);
if (next.has(sectionId)) next.delete(sectionId);
else next.add(sectionId);
saveCollapsedSections(next);
return next;
});
}, []);
const toolsByModality = useMemo(() => {
const toolsBySection = useMemo(() => {
const map = new Map<string, Map<string, Tool[]>>();
for (const tool of visibleTools) {
let modMap = map.get(tool.modality);
if (!modMap) {
modMap = new Map();
map.set(tool.modality, modMap);
const sec = toolSection(tool);
let catMap = map.get(sec);
if (!catMap) {
catMap = new Map();
map.set(sec, catMap);
}
const existing = modMap.get(tool.category);
const existing = catMap.get(tool.category);
if (existing) existing.push(tool);
else modMap.set(tool.category, [tool]);
else catMap.set(tool.category, [tool]);
}
return map;
}, [visibleTools]);
@@ -355,37 +346,36 @@ function AllTabContent({
</section>
)}
{/* Modality sections */}
{MODALITY_TAB_ORDER.map(({ modalityId, label }) => {
const categoryMap = toolsByModality.get(modalityId);
{/* Section groups */}
{SECTIONS.map((sec) => {
const categoryMap = toolsBySection.get(sec.id);
if (!categoryMap || categoryMap.size === 0) return null;
const totalCount = [...categoryMap.values()].reduce((sum, arr) => sum + arr.length, 0);
const isCollapsed = collapsed.has(modalityId);
const modality = MODALITIES.find((m) => m.id === modalityId);
const ModalityIcon = modality
? (ICON_MAP[modality.icon] as React.ComponentType<{ className?: string }>)
: null;
const isCollapsed = collapsed.has(sec.id);
const SectionIcon = ICON_MAP[sec.icon] as
| React.ComponentType<{ className?: string }>
| undefined;
return (
<section key={modalityId}>
<section key={sec.id}>
<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"
>
{ModalityIcon && (
{SectionIcon && (
<div
className="p-1 rounded"
style={{
backgroundColor: `${modality?.color ?? "#6B7280"}15`,
color: modality?.color ?? "#6B7280",
backgroundColor: `${sec.color}15`,
color: sec.color,
}}
>
<ModalityIcon className="h-4 w-4" />
<SectionIcon className="h-4 w-4" />
</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>
<div className="flex-1" />
<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 {
AlertCircle,
CheckCircle2,
@@ -233,11 +239,11 @@ export function ToolPage() {
const breadcrumb = useMemo(() => {
if (!tool) return undefined;
const modalityInfo = MODALITIES.find((m) => m.id === tool.modality);
const modalityDisplay = modalityInfo?.name ?? tool.modality;
const section = toolSection(tool);
const sectionInfo = SECTIONS.find((s) => s.id === section);
return {
modality: modalityDisplay,
modalityTab: tool.modality === "file" ? "data" : tool.modality,
modality: sectionInfo?.name ?? section,
modalityTab: section,
toolName: getToolName(t, tool.id, tool.name),
};
}, [tool, t]);
+2
View File
@@ -2489,6 +2489,8 @@ export const ar: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2503,6 +2503,8 @@ export const de: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2453,6 +2453,8 @@ export const en = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2485,6 +2485,8 @@ export const es: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2504,6 +2504,8 @@ export const fr: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2486,6 +2486,8 @@ export const hi: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2498,6 +2498,8 @@ export const id: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2497,6 +2497,8 @@ export const it: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2456,6 +2456,8 @@ export const ja: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2441,6 +2441,8 @@ export const ko: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2500,6 +2500,8 @@ export const nl: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2501,6 +2501,8 @@ export const pl: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2497,6 +2497,8 @@ export const ptBR: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2499,6 +2499,8 @@ export const ru: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2496,6 +2496,8 @@ export const sv: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2478,6 +2478,8 @@ export const th: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2501,6 +2501,8 @@ export const tr: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2499,6 +2499,8 @@ export const uk: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2498,6 +2498,8 @@ export const vi: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2431,6 +2431,8 @@ export const zhCN: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},
+2
View File
@@ -2429,6 +2429,8 @@ export const zhTW: TranslationKeys = {
all: "All",
documents: "PDF",
data: "Data",
pdf: "PDF",
files: "Files",
toolCount: "{count} tools",
heading: "SnapOtter Tools",
},