From e7126f92931e1c0277aca6c56ef06276d961ef64 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 15 Jun 2026 11:47:58 +0800 Subject: [PATCH] feat: All tab groups tools by modality with collapsible sections The All tab now shows tools organized by modality (Image, Video, Audio, Documents, Data) with each section showing a colored icon, tool count, and collapse toggle. Categories are nested within each modality. Collapse state persists in localStorage so enterprise users can hide modalities they don't use. Modality tabs still show the flat category view for focused browsing. --- apps/web/src/pages/home-page.tsx | 149 +++++++++++++++++++++++++++++-- 1 file changed, 141 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 606ec55b..882142a4 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,7 +1,7 @@ import type { Tool } from "@snapotter/shared"; -import { CATEGORIES, TOOLS } from "@snapotter/shared"; -import { Search, X } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { CATEGORIES, MODALITIES, TOOLS } 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"; import { ToolCard } from "@/components/common/tool-card.js"; import { AppLayout } from "@/components/layout/app-layout.js"; @@ -10,6 +10,7 @@ import { useFuseSearch } from "@/hooks/use-fuse-search.js"; import { usePageTitle } from "@/hooks/use-page-title.js"; import { useRecentTools } from "@/hooks/use-recent-tools.js"; import { format } from "@/lib/format.js"; +import { ICON_MAP } from "@/lib/icon-map.js"; import { getCategoryName, getToolName } from "@/lib/tool-i18n.js"; import { cn } from "@/lib/utils.js"; import { useSettingsStore } from "@/stores/settings-store"; @@ -19,6 +20,29 @@ interface TabDef { label: string; } +const COLLAPSE_STORAGE_KEY = "snapotter-collapsed-modalities"; + +function getCollapsedModalities(): Set { + try { + const raw = localStorage.getItem(COLLAPSE_STORAGE_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { + return new Set(); + } +} + +function saveCollapsedModalities(collapsed: Set) { + 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: "Documents" }, + { modalityId: "file", tabKey: "data", label: "Data" }, +]; + export function HomePage() { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("all"); @@ -113,8 +137,10 @@ export function HomePage() { {search ? ( setSearch("")} /> + ) : activeTab === "all" ? ( + ) : ( - + )} @@ -255,19 +281,46 @@ function SearchResults({ ); } -// ── Tool Grid (used by both All tab and modality tabs) ─────────── +// ── All Tab: Grouped by modality, then by category ─────────────── -function ToolGrid({ +function AllTabContent({ recentTools, - groupedTools, + visibleTools, }: { recentTools: Tool[]; - groupedTools: Map; + visibleTools: Tool[]; }) { const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState>(getCollapsedModalities); + + const toggleModality = useCallback((modalityId: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(modalityId)) next.delete(modalityId); + else next.add(modalityId); + saveCollapsedModalities(next); + return next; + }); + }, []); + + const toolsByModality = useMemo(() => { + const map = new Map>(); + for (const tool of visibleTools) { + let modMap = map.get(tool.modality); + if (!modMap) { + modMap = new Map(); + map.set(tool.modality, modMap); + } + const existing = modMap.get(tool.category); + if (existing) existing.push(tool); + else modMap.set(tool.category, [tool]); + } + return map; + }, [visibleTools]); return (
+ {/* Recent */} {recentTools.length > 0 && (

@@ -287,6 +340,86 @@ function ToolGrid({

)} + {/* Modality sections */} + {MODALITY_TAB_ORDER.map(({ modalityId, label }) => { + const categoryMap = toolsByModality.get(modalityId); + 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; + + return ( +
+ + + {!isCollapsed && ( +
+ {CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => { + const tools = categoryMap.get(category.id) ?? []; + return ( +
+

+ {getCategoryName(t, category.id, category.name)} +

+
+ {tools.map((tool) => ( + + ))} +
+
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +} + +// ── Category Grid (used by modality tabs) ──────────────────────── + +function CategoryGrid({ groupedTools }: { groupedTools: Map }) { + const { t } = useTranslation(); + + if (groupedTools.size === 0) { + return ( +

{t.fullscreenGrid.noToolsFound}

+ ); + } + + return ( +
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => { const tools = groupedTools.get(category.id) ?? []; return (