diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f9f6bff7..2eb7e8fb 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -20,9 +20,6 @@ const ChangePasswordPage = lazy(() => import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })), ); const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage }))); -const FullscreenGridPage = lazy(() => - import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })), -); const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage }))); const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage }))); const PrivacyPolicyPage = lazy(() => @@ -237,7 +234,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> {/* Redirects: old color tools consolidated into adjust-colors */} { + document.title = title ? `${title} - SnapOtter` : "SnapOtter"; + return () => { + document.title = "SnapOtter"; + }; + }, [title]); +} diff --git a/apps/web/src/pages/automate-page.tsx b/apps/web/src/pages/automate-page.tsx index 486f9c15..88b5bbb8 100644 --- a/apps/web/src/pages/automate-page.tsx +++ b/apps/web/src/pages/automate-page.tsx @@ -28,6 +28,7 @@ import { PipelineBuilder } from "@/components/tools/pipeline-builder"; import { ToolPalette } from "@/components/tools/tool-palette"; import { useTranslation } from "@/contexts/i18n-context"; import { useMobile } from "@/hooks/use-mobile"; +import { usePageTitle } from "@/hooks/use-page-title"; import { usePipelineProcessor } from "@/hooks/use-pipeline-processor"; import { formatHeaders, getFileDownloadUrl } from "@/lib/api"; import { formatFileSize } from "@/lib/download"; @@ -38,6 +39,7 @@ import { type SavedPipeline, usePipelineStore } from "@/stores/pipeline-store"; export function AutomatePage() { const { t } = useTranslation(); + usePageTitle(t.sidebar.automate); const { files, entries, diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx index 983a8213..70d9f516 100644 --- a/apps/web/src/pages/editor-page.tsx +++ b/apps/web/src/pages/editor-page.tsx @@ -22,12 +22,14 @@ import { EditorToolbar } from "@/components/editor/editor-toolbar"; import { useTranslation } from "@/contexts/i18n-context"; import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts"; import { useMobile } from "@/hooks/use-mobile"; +import { usePageTitle } from "@/hooks/use-page-title"; import { useEditorStore } from "@/stores/editor-store"; const SERVER_DECODED_EXTS = new Set(["psd", "tga", "exr", "hdr"]); export function EditorPage() { const { t } = useTranslation(); + usePageTitle(t.sidebar.editor); const isMobile = useMobile(); const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl); const isDirty = useEditorStore((s) => s.isDirty); diff --git a/apps/web/src/pages/files-page.tsx b/apps/web/src/pages/files-page.tsx index 4b6d3753..963630ec 100644 --- a/apps/web/src/pages/files-page.tsx +++ b/apps/web/src/pages/files-page.tsx @@ -7,10 +7,12 @@ import { FilesNav } from "@/components/files/files-nav"; import { AppLayout } from "@/components/layout/app-layout"; import { useTranslation } from "@/contexts/i18n-context"; import { useMobile } from "@/hooks/use-mobile"; +import { usePageTitle } from "@/hooks/use-page-title"; import { useFilesPageStore } from "@/stores/files-page-store"; export function FilesPage() { const { t } = useTranslation(); + usePageTitle(t.sidebar.files); const { activeTab, setActiveTab, selectedFileId } = useFilesPageStore(); const isMobile = useMobile(); const [showDetails, setShowDetails] = useState(false); diff --git a/apps/web/src/pages/fullscreen-grid-page.tsx b/apps/web/src/pages/fullscreen-grid-page.tsx deleted file mode 100644 index a78a1974..00000000 --- a/apps/web/src/pages/fullscreen-grid-page.tsx +++ /dev/null @@ -1,389 +0,0 @@ -import type { CategoryInfo, Modality, Tool } from "@snapotter/shared"; -import { ANALYTICS_EVENTS, CATEGORIES, MODALITIES, TOOLS } from "@snapotter/shared"; -import { - AudioLines, - Eye, - EyeOff, - FileImage, - FileText, - Image, - LayoutGrid, - List, - Search, - Table, - Video, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { OtterLogo } from "@/components/common/otter-logo"; -import { MobileBottomNav } from "@/components/layout/mobile-bottom-nav"; -import { useTranslation } from "@/contexts/i18n-context"; -import { useFuseSearch } from "@/hooks/use-fuse-search"; -import { useMobile } from "@/hooks/use-mobile"; -import { track } from "@/lib/analytics"; -import { apiGet } from "@/lib/api"; -import { ICON_MAP } from "@/lib/icon-map"; -import { getCategoryName, getModalityName, getToolDescription, getToolName } from "@/lib/tool-i18n"; -import { cn } from "@/lib/utils"; -import { useFeaturesStore } from "@/stores/features-store"; - -const MODALITY_TABS = [ - { id: "all", label: "All", icon: LayoutGrid }, - { id: "image", label: "Image", icon: Image }, - { id: "video", label: "Video", icon: Video }, - { id: "audio", label: "Audio", icon: AudioLines }, - { id: "document", label: "Docs", icon: FileText }, -] as const; - -export function FullscreenGridPage() { - const { t } = useTranslation(); - const isMobile = useMobile(); - const [search, setSearch] = useState(""); - const [showDetails, setShowDetails] = useState(true); - const navigate = useNavigate(); - const [disabledTools, setDisabledTools] = useState([]); - const [experimentalEnabled, setExperimentalEnabled] = useState(false); - const [modalityTab, setModalityTab] = useState("all"); - const fetchFeatures = useFeaturesStore((s) => s.fetch); - - useEffect(() => { - fetchFeatures(); - }, [fetchFeatures]); - - useEffect(() => { - apiGet<{ settings: Record }>("/v1/settings") - .then((data) => { - setDisabledTools( - data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [], - ); - setExperimentalEnabled(data.settings.enableExperimentalTools === "true"); - }) - .catch(() => {}); - }, []); - - const visibleTools = useMemo(() => { - return TOOLS.filter((t) => { - if (disabledTools.includes(t.id)) return false; - if (t.experimental && !experimentalEnabled) return false; - return true; - }); - }, [disabledTools, experimentalEnabled]); - - const modalityFiltered = useMemo(() => { - if (modalityTab === "all") return visibleTools; - if (modalityTab === "document") - return visibleTools.filter((t) => t.modality === "document" || t.modality === "file"); - return visibleTools.filter((t) => t.modality === modalityTab); - }, [visibleTools, modalityTab]); - - const filteredTools = useFuseSearch(modalityFiltered, search); - - useEffect(() => { - if (!search) return; - const timer = setTimeout(() => { - track(ANALYTICS_EVENTS.SEARCH, { - query: search, - results_count: filteredTools.length, - }); - }, 1000); - return () => clearTimeout(timer); - }, [search, filteredTools.length]); - - const groupedTools = useMemo(() => { - const groups = new Map(); - for (const tool of filteredTools) { - const list = groups.get(tool.category) || []; - list.push(tool); - groups.set(tool.category, list); - } - return groups; - }, [filteredTools]); - - const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id)); - - /** Map each modality to its list of active categories (for "All" tab grouping). */ - const modalitySections = useMemo(() => { - if (modalityTab !== "all") return null; - const catToModality = new Map(); - for (const tool of filteredTools) { - const key = tool.modality === "file" ? ("document" as Modality) : tool.modality; - if (!catToModality.has(tool.category)) { - catToModality.set(tool.category, key); - } - } - const sections: { modality: (typeof MODALITIES)[number]; categories: CategoryInfo[] }[] = []; - for (const mod of MODALITIES) { - if (mod.id === "file") continue; - const cats = activeCategories.filter((c) => catToModality.get(c.id) === mod.id); - if (cats.length > 0) sections.push({ modality: mod, categories: cats }); - } - return sections; - }, [modalityTab, filteredTools, activeCategories]); - - return ( -
- {/* Top bar */} -
-
- - - SnapOtter - - - {/* Search */} -
- - setSearch(e.target.value)} - placeholder={t.common.search} - className="w-full ps-10 pe-4 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" - /> -
- - {/* Toggle details */} - - - {/* Switch to sidebar view */} - -
-
- - {/* Modality tabs */} -
-
- {MODALITY_TABS.map((tab) => { - const TabIcon = tab.icon; - const isActive = modalityTab === tab.id; - return ( - - ); - })} -
-
- - {/* Grid */} -
-

{t.nav.tools}

- {activeCategories.length === 0 ? ( -
- -

{t.fullscreenGrid.noToolsFound}

-

{t.fullscreenGrid.tryDifferent}

-
- ) : modalitySections ? ( - /* "All" tab: render with modality section headers */ -
- {modalitySections.map((section) => { - const SectionIcon = ICON_MAP[section.modality.icon] as React.ComponentType<{ - className?: string; - }>; - return ( -
-
- {SectionIcon && ( -
- -
- )} -

- {getModalityName( - t, - section.modality.id, - section.modality.id === "document" - ? "Documents & Files" - : section.modality.name, - )} -

-
-
- {section.categories.map((category) => ( - - ))} -
-
- ); - })} -
- ) : ( - /* Specific modality tab selected */ -
- {(() => { - const activeMod = MODALITIES.find((m) => m.id === modalityTab); - const ModIcon = activeMod - ? (ICON_MAP[activeMod.icon] as React.ComponentType<{ className?: string }>) - : null; - return activeMod ? ( -
- {ModIcon && ( -
- -
- )} -

- {getModalityName( - t, - activeMod.id, - activeMod.id === "document" ? "Documents & Files" : activeMod.name, - )} -

-
- ) : null; - })()} -
- {activeCategories.map((category) => { - const activeMod = MODALITIES.find((m) => m.id === modalityTab); - return ( - - ); - })} -
-
- )} -
- {isMobile && } -
- ); -} - -function CategoryCard({ - category, - tools, - showDetails, - accentColor, -}: { - category: CategoryInfo; - tools: Tool[]; - showDetails: boolean; - accentColor?: string; -}) { - const { t } = useTranslation(); - const CategoryIcon = - (ICON_MAP[category.icon] as React.ComponentType<{ className?: string }>) ?? LayoutGrid; - - return ( -
- {/* Header */} -
-
- -
-
-

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

-
- - {tools.length} - -
- - {/* Tool list */} -
- {tools.map((tool) => { - const ToolIcon = - (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage; - return ( - - -
-

- {getToolName(t, tool.id, tool.name)} -

- {showDetails && ( -

- {getToolDescription(t, tool.id, tool.description)} -

- )} -
- {tool.experimental && ( - - {t.common.experimental} - - )} - - ); - })} -
-
- ); -} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 208fdfdc..951b6c3e 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,6 +1,9 @@ import { AppLayout } from "@/components/layout/app-layout"; +import { usePageTitle } from "@/hooks/use-page-title"; export function HomePage() { + usePageTitle(); + return (
diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index cc2a7fff..61d3528c 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -26,6 +26,7 @@ import type { PreviewTransform } from "@/components/tools/rotate-settings"; import { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; import { useMobile } from "@/hooks/use-mobile"; +import { usePageTitle } from "@/hooks/use-page-title"; import { formatFileSize } from "@/lib/download"; import { format } from "@/lib/format"; import { ICON_MAP } from "@/lib/icon-map"; @@ -178,6 +179,7 @@ export function ToolPage() { }, [toolId, featureBundles]); const toolInstalled = featureBundle ? featureBundle.status === "installed" : !isAiTool; const showSizeComparison = toolId === "compress" || toolId === "optimize-for-web"; + usePageTitle(tool ? getToolName(t, tool.id, tool.name) : undefined); const { hasPermission } = useAuth(); const isAdmin = hasPermission("settings:write");