import type { Tool } from "@snapotter/shared"; import { ANALYTICS_EVENTS, CATEGORIES, SECTIONS, TOOLS, toolSection } from "@snapotter/shared"; import { ChevronDown, Plus, 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 { FeedbackDialog } from "@/components/feedback/feedback-dialog.js"; import { AppLayout } from "@/components/layout/app-layout.js"; import { useTranslation } from "@/contexts/i18n-context"; 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 type { FeedbackPromptVariant } from "@/lib/feedback.js"; import { format } from "@/lib/format.js"; import { ICON_MAP } from "@/lib/icon-map.js"; import { getCategoryName, getToolName } from "@/lib/tool-i18n.js"; import { buildToolRequestDiscussionUrl } from "@/lib/tool-request.js"; import { cn } from "@/lib/utils.js"; import { useAnalyticsStore } from "@/stores/analytics-store"; import { usePinnedToolsStore } from "@/stores/pinned-tools-store"; import { useSettingsStore } from "@/stores/settings-store"; interface TabDef { key: string; label: string; } const COLLAPSE_STORAGE_KEY = "snapotter-collapsed-sections"; function getCollapsedSections(): Set { try { const raw = localStorage.getItem(COLLAPSE_STORAGE_KEY); return raw ? new Set(JSON.parse(raw)) : new Set(); } catch { return new Set(); } } function saveCollapsedSections(collapsed: Set) { localStorage.setItem(COLLAPSE_STORAGE_KEY, JSON.stringify([...collapsed])); } const SECTION_TABS = new Set(["all", ...SECTIONS.map((s) => s.id)]); export function HomePage() { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState("all"); const [search, setSearch] = useState(""); const { fetch: fetchSettings, disabledTools, experimentalEnabled, loaded } = useSettingsStore(); const recentToolIds = useRecentTools(); const pinnedIds = usePinnedToolsStore((s) => s.pinnedTools); const fetchPins = usePinnedToolsStore((s) => s.fetch); const analyticsConfig = useAnalyticsStore((s) => s.config); const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); const analyticsOn = analyticsConfigLoaded && analyticsConfig?.enabled === true; const [requestOpen, setRequestOpen] = useState(false); const [requestVariant, setRequestVariant] = useState("search-empty-v1"); const openRequest = useCallback((variant: FeedbackPromptVariant) => { setRequestVariant(variant); setRequestOpen(true); }, []); const location = useLocation(); const navigate = useNavigate(); usePageTitle(); useEffect(() => { fetchSettings(); }, [fetchSettings]); useEffect(() => { fetchPins(); }, [fetchPins]); // Open a specific section tab when arriving via a breadcrumb link // (/?section=), then clean the URL so refresh/back doesn't re-pin it. useEffect(() => { const s = new URLSearchParams(location.search).get("section"); if (s && SECTION_TABS.has(s)) { setActiveTab(s); navigate("/", { replace: true }); } }, [location.search, navigate]); const tabs: TabDef[] = useMemo( () => [ { key: "all", label: t.homePage.all }, ...SECTIONS.map((s) => ({ key: s.id, label: s.id === "pdf" ? t.homePage.pdf : s.id === "files" ? t.homePage.files : s.name, })), ], [t], ); const visibleTools = useMemo(() => { if (!loaded) return []; return TOOLS.filter((tool) => { if (tool.disabled) return false; if (disabledTools.includes(tool.id)) return false; if (tool.experimental && !experimentalEnabled) return false; return true; }); }, [disabledTools, experimentalEnabled, loaded]); const searchResults = useFuseSearch(visibleTools, search); useEffect(() => { if (!search || search.length < 2) return; const timer = setTimeout(() => { import("@/lib/analytics").then(({ track }) => { track(ANALYTICS_EVENTS.SEARCH, { results_count: searchResults.length }); }); }, 1000); return () => clearTimeout(timer); }, [search, searchResults.length]); const tabTools = useMemo(() => { if (activeTab === "all") return visibleTools; return visibleTools.filter((tool) => toolSection(tool) === activeTab); }, [visibleTools, activeTab]); const groupedTools = useMemo(() => { const map = new Map(); for (const tool of tabTools) { const existing = map.get(tool.category); if (existing) { existing.push(tool); } else { map.set(tool.category, [tool]); } } return map; }, [tabTools]); const tabCounts = useMemo(() => { const counts: Record = { all: visibleTools.length }; for (const tool of visibleTools) { const sec = toolSection(tool); counts[sec] = (counts[sec] ?? 0) + 1; } return counts; }, [visibleTools]); const pinnedTools = useMemo(() => { const byId = new Map(visibleTools.map((tool) => [tool.id, tool])); return pinnedIds.map((id) => byId.get(id)).filter((tool): tool is Tool => tool != null); }, [pinnedIds, visibleTools]); const recentTools = useMemo(() => { const pinnedSet = new Set(pinnedIds); return recentToolIds .filter((id) => !pinnedSet.has(id)) .map((id) => visibleTools.find((tool) => tool.id === id)) .filter((tool): tool is Tool => tool != null); }, [recentToolIds, visibleTools, pinnedIds]); return (

{t.homePage.heading}

{search ? ( setSearch("")} analyticsOn={analyticsOn} onRequest={openRequest} /> ) : activeTab === "all" ? ( ) : ( )} setRequestOpen(false)} />
); } // ── Search Bar ─────────────────────────────────────────────────── function HomeSearchBar({ value, onChange, placeholder, }: { value: string; onChange: (v: string) => void; placeholder: string; }) { const inputRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); useEffect(() => { const params = new URLSearchParams(location.search); if (params.get("focus") === "search") { inputRef.current?.focus(); navigate("/", { replace: true }); } }, [location.search, navigate]); return (
onChange(e.target.value)} onKeyDown={(e) => { // Escape steps out of the search field: clear a query if present, // otherwise drop focus back to the page. if (e.key === "Escape") { if (value) onChange(""); e.currentTarget.blur(); } }} placeholder={placeholder} aria-label={placeholder} className="w-full ps-11 pe-20 py-2.5 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/40 transition-shadow" /> {value ? ( ) : ( K )}
); } // ── Modality Tabs ──────────────────────────────────────────────── function ModalityTabs({ tabs, activeTab, onTabChange, counts, }: { tabs: TabDef[]; activeTab: string; onTabChange: (key: string) => void; counts: Record; }) { return (
{tabs.map((tab) => ( ))}
); } // ── Search Results ─────────────────────────────────────────────── function RequestToolAffordance({ query, variant, analyticsOn, onRequest, }: { query: string; variant: "empty" | "below"; analyticsOn: boolean; onRequest: (promptVariant: FeedbackPromptVariant) => void; }) { const { t } = useTranslation(); const label = variant === "empty" ? format(t.homePage.requestToolCta, { query }) : t.homePage.requestToolBelowResults; const promptVariant: FeedbackPromptVariant = variant === "empty" ? "search-empty-v1" : "search-results-v1"; const className = "inline-flex items-center gap-1.5 text-sm text-primary hover:underline"; if (analyticsOn) { return ( ); } return ( ); } function SearchResults({ results, query, onClear, analyticsOn, onRequest, }: { results: Tool[]; query: string; onClear: () => void; analyticsOn: boolean; onRequest: (promptVariant: FeedbackPromptVariant) => void; }) { const { t } = useTranslation(); if (results.length === 0) { return (

{format(t.homePage.noToolsMatch, { query })}

); } return (
{results.map((tool) => ( { // search -> click attribution: which result the user opened. import("@/lib/analytics").then(({ track }) => track(ANALYTICS_EVENTS.SEARCH, { results_count: results.length, clicked_tool_id: tool.id, }), ); }} /> ))}
); } // ── All Tab: Grouped by section, then by category ─────────────── function AllTabContent({ pinnedTools, recentTools, visibleTools, }: { pinnedTools: Tool[]; recentTools: Tool[]; visibleTools: Tool[]; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState>(getCollapsedSections); const toggleSection = useCallback((sectionId: string) => { setCollapsed((prev) => { const next = new Set(prev); if (next.has(sectionId)) next.delete(sectionId); else next.add(sectionId); saveCollapsedSections(next); return next; }); }, []); const toolsBySection = useMemo(() => { const map = new Map>(); for (const tool of visibleTools) { const sec = toolSection(tool); let catMap = map.get(sec); if (!catMap) { catMap = new Map(); map.set(sec, catMap); } const existing = catMap.get(tool.category); if (existing) existing.push(tool); else catMap.set(tool.category, [tool]); } return map; }, [visibleTools]); return (
{/* Pinned */} {pinnedTools.length > 0 && (

{t.homePage.pinned}

{pinnedTools.map((tool) => ( ))}
)} {/* Recent */} {recentTools.length > 0 && (

{t.homePage.recent}

{recentTools.map((tool) => ( {getToolName(t, tool.id, tool.name)} ))}
)} {/* 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(sec.id); const SectionIcon = ICON_MAP[sec.icon] as | React.ComponentType<{ className?: string }> | undefined; 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 (

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

{tools.map((tool) => ( ))}
); })}
); }