feat: replace sidebar with top nav bar across all pages

Remove the Sidebar and ToolPanel components, replacing them with the
TopNav bar integrated into AppLayout. Strip the home page to a minimal
placeholder (Task 6 will rebuild it as a tool browser). Add breadcrumb
navigation to tool pages showing modality and tool name.
This commit is contained in:
SnapOtter
2026-06-14 18:30:32 +08:00
parent f79958a622
commit 71fef439a0
7 changed files with 37 additions and 795 deletions
+16 -128
View File
@@ -1,39 +1,22 @@
import { Globe, Menu, X } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useState } from "react";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { useConnectionStore } from "@/stores/connection-store";
import { Dropzone } from "../common/dropzone";
import { OtterLogo } from "../common/otter-logo";
import { HelpDialog } from "../help/help-dialog";
import { SettingsDialog } from "../settings/settings-dialog";
import { AiInstallIndicator } from "./ai-install-indicator";
import { Footer } from "./footer";
import { MobileBottomNav } from "./mobile-bottom-nav";
import { Sidebar } from "./sidebar";
import { ToolPanel } from "./tool-panel";
import { TopNav } from "./top-nav.js";
interface AppLayoutProps {
children?: React.ReactNode;
showToolPanel?: boolean;
onFiles?: (files: File[]) => void;
onUrlImport?: (file: File) => void;
breadcrumb?: { modality?: string; toolName?: string };
navVariant?: "light" | "dark";
}
export function AppLayout({
children,
showToolPanel = true,
onFiles,
onUrlImport,
}: AppLayoutProps) {
export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const mobileSidebarRef = useRef<HTMLDivElement>(null);
useFocusTrap(mobileSidebarRef, mobileSidebarOpen);
const { t, locale, setLocale, supportedLocales } = useTranslation();
const isMobile = useMobile();
const connectionStatus = useConnectionStore((s) => s.status);
const bannerVisible = connectionStatus !== "connected";
@@ -41,121 +24,26 @@ export function AppLayout({
return (
<div
className={cn(
"flex h-screen bg-background text-foreground overflow-hidden",
"flex flex-col h-screen bg-background text-foreground overflow-hidden",
bannerVisible && "pt-9",
)}
>
{/* Desktop sidebar */}
{!isMobile && (
<Sidebar
onSettingsClick={() => setSettingsOpen(true)}
onHelpClick={() => setHelpOpen(true)}
/>
)}
{/* Mobile sidebar overlay */}
{isMobile && mobileSidebarOpen && (
<>
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm cursor-default"
onClick={() => setMobileSidebarOpen(false)}
/>
<div
ref={mobileSidebarRef}
role="dialog"
aria-modal="true"
aria-label={t.a11y.openSidebar}
className="fixed inset-y-0 left-0 z-50 w-64 bg-background border-r border-border shadow-xl animate-in slide-in-from-left"
>
<div className="flex items-center justify-between p-3 border-b border-border">
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
<span className="text-sm font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</span>
</div>
<button
type="button"
onClick={() => setMobileSidebarOpen(false)}
className="p-2.5 rounded-lg hover:bg-muted"
aria-label={t.a11y.closeSidebar}
>
<X className="h-5 w-5" />
</button>
</div>
<Sidebar
onSettingsClick={() => {
setMobileSidebarOpen(false);
setSettingsOpen(true);
}}
onHelpClick={() => {
setMobileSidebarOpen(false);
setHelpOpen(true);
}}
onNavClick={() => setMobileSidebarOpen(false)}
expanded
/>
<div className="border-t border-border p-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<Globe className="h-4 w-4" />
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground border-none outline-none"
>
{supportedLocales.map((l) => (
<option key={l.code} value={l.code}>
{l.nativeName}
</option>
))}
</select>
</label>
</div>
</div>
</>
)}
{/* Mobile top bar */}
{isMobile && (
<div
className={cn(
"fixed left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border px-3 py-2 flex items-center gap-3",
bannerVisible ? "top-9" : "top-0",
)}
>
<button
type="button"
onClick={() => setMobileSidebarOpen(true)}
className="p-2.5 -ms-1 rounded-lg hover:bg-muted"
aria-label={t.a11y.openSidebar}
>
<Menu className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<OtterLogo className="h-5 w-5 text-primary" />
<span className="text-sm font-bold text-foreground">
<span className="text-primary">SnapOtter</span>
</span>
</div>
</div>
)}
{showToolPanel && !isMobile && <ToolPanel />}
{/* Top navigation bar */}
<TopNav
variant={navVariant}
breadcrumb={breadcrumb}
onHelpClick={() => setHelpOpen(true)}
onSettingsClick={() => setSettingsOpen(true)}
/>
{/* Main content area */}
<main
id="main-content"
className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pt-12 pb-20")}
className={cn("flex-1 flex flex-col overflow-hidden", isMobile && "pb-20")}
>
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
{children || (
<Dropzone onFiles={onFiles} onUrlImport={onUrlImport} fileFilter={() => true} />
)}
</div>
{children}
</main>
{!isMobile && <Footer />}
{/* Mobile bottom nav */}
{isMobile && <MobileBottomNav onSettingsClick={() => setSettingsOpen(true)} />}
-128
View File
@@ -1,128 +0,0 @@
import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react";
import type { ComponentType } from "react";
import { Link, useLocation } from "react-router-dom";
import { useTranslation } from "@/contexts/i18n-context";
import { cn } from "@/lib/utils";
import { ImageEditIcon } from "../common/image-edit-icon";
import { OtterLogo } from "../common/otter-logo";
interface SidebarItem {
icon: ComponentType<{ className?: string }>;
label: string;
href?: string;
}
function useNavItems() {
const { t } = useTranslation();
const topItems: SidebarItem[] = [
{ icon: LayoutGrid, label: t.sidebar.tools, href: "/" },
{ icon: Grid3x3, label: t.sidebar.grid, href: "/fullscreen" },
{ icon: Workflow, label: t.sidebar.automate, href: "/automate" },
{ icon: ImageEditIcon, label: t.sidebar.editor, href: "/editor" },
{ icon: FolderOpen, label: t.sidebar.files, href: "/files" },
];
const bottomItems: SidebarItem[] = [
{ icon: HelpCircle, label: t.sidebar.help },
{ icon: Settings, label: t.sidebar.settings },
];
return { topItems, bottomItems };
}
interface SidebarProps {
onSettingsClick: () => void;
onHelpClick: () => void;
/** Called when a nav link is clicked (e.g., to close mobile sidebar). */
onNavClick?: () => void;
/** When true, renders in expanded mode (for mobile overlay). */
expanded?: boolean;
}
export function Sidebar({
onSettingsClick,
onHelpClick,
onNavClick,
expanded = false,
}: SidebarProps) {
const location = useLocation();
const { t } = useTranslation();
const { topItems, bottomItems } = useNavItems();
const renderItem = (item: SidebarItem, isActive: boolean) => {
const content = expanded ? (
<div
className={cn(
"flex items-center gap-3 px-4 py-2.5 rounded-lg cursor-pointer transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-5 w-5" />
<span className="text-sm font-medium">{item.label}</span>
</div>
) : (
<div
className={cn(
"flex flex-col items-center gap-1 p-2 rounded-lg cursor-pointer transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-6 w-6" />
<span className="text-[10px] font-medium">{item.label}</span>
</div>
);
if (item === bottomItems[1]) {
return (
<button key={item.label} type="button" onClick={onSettingsClick} className="w-full">
{content}
</button>
);
}
if (item === bottomItems[0]) {
return (
<button
key={item.label}
type="button"
onClick={onHelpClick}
className="w-full text-sidebar-foreground"
>
{content}
</button>
);
}
return (
<Link key={item.label} to={item.href || "/"} onClick={onNavClick}>
{content}
</Link>
);
};
if (expanded) {
return (
<nav aria-label={t.a11y.navigationMenu} className="flex flex-col p-3 gap-1">
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
<div className="border-t border-border my-2" />
{bottomItems.map((item) => renderItem(item, false))}
</nav>
);
}
return (
<aside className="flex flex-col items-center w-16 bg-sidebar border-e border-border py-3 gap-1 shrink-0">
<div className="mb-2 flex items-center justify-center">
<OtterLogo className="h-7 w-7 text-primary" />
</div>
<div className="border-t border-border w-10 mb-2" />
<nav aria-label={t.a11y.navigationMenu} className="flex flex-col gap-1 flex-1">
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
</nav>
<div className="border-t border-border w-10 my-2" />
<div className="flex flex-col gap-1">
{bottomItems.map((item) => renderItem(item, false))}
</div>
</aside>
);
}
@@ -1,157 +0,0 @@
import { CATEGORIES, MODALITIES, type Modality, TOOLS } from "@snapotter/shared";
import { LayoutGrid } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFuseSearch } from "@/hooks/use-fuse-search";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getModalityName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
import { useFeaturesStore } from "@/stores/features-store";
import { useSettingsStore } from "@/stores/settings-store";
import { SearchBar } from "../common/search-bar";
import { ToolCard } from "../common/tool-card";
type ModalityFilter = "all" | "document" | Exclude<Modality, "file">;
const TAB_MODALITIES = MODALITIES.filter((m) => m.id !== "file");
export function ToolPanel() {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [selectedModality, setSelectedModality] = useState<ModalityFilter>("all");
const { disabledTools, experimentalEnabled, loaded, fetch } = useSettingsStore();
const fetchFeatures = useFeaturesStore((s) => s.fetch);
useEffect(() => {
fetch();
}, [fetch]);
useEffect(() => {
fetchFeatures();
}, [fetchFeatures]);
const visibleTools = useMemo(() => {
if (!loaded) return [];
return TOOLS.filter((t) => {
if (disabledTools.includes(t.id)) return false;
if (t.experimental && !experimentalEnabled) return false;
return true;
});
}, [disabledTools, experimentalEnabled, loaded]);
const modalityFilteredTools = useMemo(() => {
if (selectedModality === "all") return visibleTools;
if (selectedModality === "document")
return visibleTools.filter(
(tool) => tool.modality === "document" || tool.modality === "file",
);
return visibleTools.filter((tool) => tool.modality === selectedModality);
}, [visibleTools, selectedModality]);
const filteredTools = useFuseSearch(modalityFilteredTools, search);
const groupedByModality = useMemo(() => {
const byModality = new Map<string, Map<string, typeof TOOLS>>();
for (const tool of filteredTools) {
const key = tool.modality === "file" ? "document" : tool.modality;
const cats = byModality.get(key) ?? new Map<string, typeof TOOLS>();
const list = cats.get(tool.category) ?? [];
list.push(tool);
cats.set(tool.category, list);
byModality.set(key, cats);
}
return byModality;
}, [filteredTools, selectedModality]);
return (
<div className="w-72 border-r border-border bg-background overflow-y-auto flex flex-col shrink-0">
<div className="p-3 sticky top-0 bg-background z-10">
{/* Modality filter tabs -- icons + text, docs+files merged */}
<div className="flex flex-wrap gap-1 mb-2">
<button
type="button"
onClick={() => setSelectedModality("all")}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
selectedModality === "all"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
<span>All</span>
</button>
{TAB_MODALITIES.map((m) => {
const Icon = ICON_MAP[m.icon] as React.ComponentType<{ className?: string }>;
const isActive = selectedModality === m.id;
const label = m.id === "document" ? "Docs" : m.name;
return (
<button
key={m.id}
type="button"
onClick={() => setSelectedModality(m.id as ModalityFilter)}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
!isActive && "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
style={isActive ? { backgroundColor: `${m.color}20`, color: m.color } : undefined}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
<span>{label}</span>
</button>
);
})}
</div>
<SearchBar value={search} onChange={setSearch} />
</div>
<div className="px-3 pb-4 flex-1">
{MODALITIES.filter((m) => {
// File-modality tools always merge into the document section
if (m.id === "file") return false;
return groupedByModality.has(m.id);
}).map((modality, idx, arr) => {
const ModalityIcon = ICON_MAP[modality.icon] as React.ComponentType<{
className?: string;
}>;
const categoryMap = groupedByModality.get(modality.id);
if (!categoryMap) return null;
const isLast = idx === arr.length - 1;
return (
<div key={modality.id} className={isLast ? "" : "mb-2"}>
{idx > 0 && <hr className="border-border mb-4" />}
<div className="flex items-center gap-2 mt-4 mb-3 pl-1">
{ModalityIcon && (
<span className="shrink-0" style={{ color: modality.color }}>
<ModalityIcon className="h-4.5 w-4.5" />
</span>
)}
<h2 className="text-sm font-semibold text-foreground tracking-wide">
{getModalityName(
t,
modality.id,
modality.id === "document" ? "Documents & Files" : modality.name,
)}
</h2>
</div>
{CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => (
<div key={category.id} className="mb-3">
<h3 className="text-xs font-medium text-muted-foreground tracking-wider mb-1.5 pl-1">
{getCategoryName(t, category.id, category.name)}
</h3>
<div className="space-y-0.5">
{categoryMap.get(category.id)?.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
</div>
))}
</div>
);
})}
{filteredTools.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -364,7 +364,7 @@ export function AutomatePage() {
/* ------------------------------------------------------------------ */
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
<AppLayout>
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
@@ -575,7 +575,7 @@ export function AutomatePage() {
/* Desktop Layout */
/* ------------------------------------------------------------------ */
return (
<AppLayout showToolPanel={false}>
<AppLayout>
<div className="flex h-full w-full overflow-hidden">
{/* LEFT PANE — Tool Palette */}
<div className="w-72 border-r border-border flex flex-col shrink-0">
+2 -2
View File
@@ -17,7 +17,7 @@ export function FilesPage() {
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
<AppLayout>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile tabs */}
@@ -95,7 +95,7 @@ export function FilesPage() {
}
return (
<AppLayout showToolPanel={false}>
<AppLayout>
<h1 className="sr-only">{t.files.myFiles}</h1>
<div className="flex h-full w-full overflow-hidden">
<FilesNav />
+3 -375
View File
@@ -1,382 +1,10 @@
import {
CATEGORIES,
MODALITIES,
PYTHON_SIDECAR_TOOLS,
TOOL_BUNDLE_MAP,
TOOLS,
} from "@snapotter/shared";
import { Clock, Download, FileArchive, LayoutGrid, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ImageViewer } from "@/components/common/image-viewer";
import { MultiImageViewer } from "@/components/common/multi-image-viewer";
import { AppLayout } from "@/components/layout/app-layout";
import { DocumentView } from "@/components/tools/document-view";
import { MediaPlayerView } from "@/components/tools/media-player-view";
import { useTranslation } from "@/contexts/i18n-context";
import { useMobile } from "@/hooks/use-mobile";
import { ICON_MAP } from "@/lib/icon-map";
import { getCategoryName, getModalityName, getToolName } from "@/lib/tool-i18n";
import { cn } from "@/lib/utils";
import { useFeaturesStore } from "@/stores/features-store";
import { useFileStore } from "@/stores/file-store";
import { useSettingsStore } from "@/stores/settings-store";
// Tools shown prominently as "quick actions" at the top
let hasAppliedDefaultRedirect = false;
export function HomePage() {
const { t } = useTranslation();
const {
setFiles,
files,
reset,
originalBlobUrl,
selectedFileName,
selectedFileSize,
currentEntry,
} = useFileStore();
const navigate = useNavigate();
const location = useLocation();
const { fetch: fetchSettings, defaultToolView, loaded: settingsLoaded } = useSettingsStore();
const { fetch: fetchFeatures, bundles, installing, queued } = useFeaturesStore();
const isMobile = useMobile();
useEffect(() => {
if (location.state?.fromLibrary) {
navigate(".", { replace: true, state: {} });
} else {
reset();
}
}, [reset, location.state, navigate]);
useEffect(() => {
fetchSettings();
fetchFeatures();
}, [fetchSettings, fetchFeatures]);
useEffect(() => {
if (
!hasAppliedDefaultRedirect &&
settingsLoaded &&
defaultToolView === "fullscreen" &&
files.length === 0
) {
hasAppliedDefaultRedirect = true;
navigate("/fullscreen", { replace: true });
}
}, [settingsLoaded, defaultToolView, files.length, navigate]);
const getToolStatus = useMemo(() => {
return (toolId: string) => {
const isAi = (PYTHON_SIDECAR_TOOLS as readonly string[]).includes(toolId);
if (!isAi) return "installed";
const bundleId = TOOL_BUNDLE_MAP[toolId];
if (!bundleId) return "installed";
if (queued.includes(bundleId)) return "queued";
if (installing[bundleId]) return "installing";
const bundle = bundles.find((b) => b.id === bundleId);
return bundle?.status === "installed" ? "installed" : "not_installed";
};
}, [bundles, installing, queued]);
const handleFiles = useCallback(
(newFiles: File[]) => {
reset();
setFiles(newFiles);
},
[setFiles, reset],
);
const handleUrlImport = useCallback(
(file: File) => {
setFiles([file]);
},
[setFiles],
);
const [modalityFilter, setModalityFilter] = useState<string>("all");
const TAB_MODALITIES = useMemo(() => MODALITIES.filter((m) => m.id !== "file"), []);
const hasFile = files.length > 0;
// If no file uploaded, show default layout (tool panel + dropzone)
if (!hasFile) {
return <AppLayout onFiles={handleFiles} onUrlImport={handleUrlImport} />;
}
// File uploaded — mobile: stacked layout
if (isMobile && hasFile) {
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex flex-col h-full w-full">
{/* File info bar */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<ICON_MAP.CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<span className="truncate text-sm font-medium text-foreground">
{selectedFileName ?? files[0].name}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""}
</span>
<button
type="button"
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground ms-auto shrink-0"
>
{t.homePage.changeFile}
</button>
</div>
{/* Full-width file preview */}
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
{files.length > 1 ? (
<MultiImageViewer />
) : currentEntry?.previewLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
) : currentEntry?.previewKind === "video" || currentEntry?.previewKind === "audio" ? (
<MediaPlayerView />
) : currentEntry?.previewKind === "document" ? (
<DocumentView />
) : currentEntry?.previewKind === "none" ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<FileArchive className="h-12 w-12 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">
{selectedFileName ?? files[0].name}
</p>
<p className="text-xs text-muted-foreground">
{selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""}
</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
/>
) : (
<div className="text-center text-muted-foreground">
<p>{t.homePage.loadingPreview}</p>
</div>
)}
</div>
</div>
</AppLayout>
);
}
// File uploaded — desktop: tool selector on left, image preview on right
return (
<AppLayout showToolPanel={false} onFiles={handleFiles}>
<h1 className="sr-only">{t.nav.tools}</h1>
<div className="flex h-full w-full">
{/* Left panel: Tool selector */}
<div className="w-64 lg:w-80 border-r border-border overflow-y-auto shrink-0">
{/* File info */}
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2 text-sm">
<ICON_MAP.CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
<span className="truncate font-medium text-foreground">
{selectedFileName ?? files[0].name}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""}
{files.length > 1 && `${files.length} files`}
</p>
<button
type="button"
onClick={reset}
className="text-xs text-muted-foreground hover:text-foreground mt-2"
>
{t.homePage.changeFile}
</button>
</div>
{/* Modality filter tabs */}
<div className="flex flex-wrap gap-1 px-4 pt-3 pb-2 border-b border-border sticky top-0 bg-background z-10">
<button
type="button"
onClick={() => setModalityFilter("all")}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
modalityFilter === "all"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<LayoutGrid className="h-3.5 w-3.5" />
<span>All</span>
</button>
{TAB_MODALITIES.map((m) => {
const Icon = ICON_MAP[m.icon] as React.ComponentType<{ className?: string }>;
const isActive = modalityFilter === m.id;
const label = m.id === "document" ? "Docs" : m.name;
return (
<button
key={m.id}
type="button"
onClick={() => setModalityFilter(m.id)}
className={cn(
"flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{Icon && <Icon className="h-3.5 w-3.5" />}
<span>{label}</span>
</button>
);
})}
</div>
{/* Tools by modality and category */}
<div className="p-4">
{MODALITIES.filter((m) => {
if (m.id === "file") return false;
if (modalityFilter !== "all") {
if (modalityFilter === "document") return m.id === "document";
return m.id === modalityFilter;
}
const key = m.id;
return TOOLS.some(
(tool) => tool.modality === key || (key === "document" && tool.modality === "file"),
);
}).map((modality) => {
const ModalityIcon = ICON_MAP[modality.icon] as React.ComponentType<{
className?: string;
}>;
const modalityTools = TOOLS.filter(
(tool) =>
tool.modality === modality.id ||
(modality.id === "document" && tool.modality === "file"),
);
const categoryMap = new Map<string, typeof TOOLS>();
for (const tool of modalityTools) {
const list = categoryMap.get(tool.category) ?? [];
list.push(tool);
categoryMap.set(tool.category, list);
}
return (
<div key={modality.id} className="mb-5">
<div className="flex items-center gap-1.5 mb-2">
{ModalityIcon && (
<ModalityIcon className="h-4 w-4 text-foreground/70 shrink-0" />
)}
<p className="text-xs font-bold uppercase text-foreground/70 tracking-wider">
{getModalityName(
t,
modality.id,
modality.id === "document" ? "Documents & Files" : modality.name,
)}
</p>
</div>
{CATEGORIES.filter((cat) => categoryMap.has(cat.id)).map((category) => (
<div key={category.id} className="mb-4">
<p
className="text-xs font-medium text-muted-foreground mb-1.5"
style={{ color: category.color }}
>
{getCategoryName(t, category.id, category.name)}
</p>
<div className="space-y-0.5">
{categoryMap.get(category.id)?.map((tool) => {
const Icon =
(ICON_MAP[tool.icon] as React.ComponentType<{
className?: string;
}>) ?? ICON_MAP.FileImage;
const status = getToolStatus(tool.id);
return (
<button
key={tool.id}
type="button"
onClick={() => navigate(tool.route)}
className="flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-start transition-colors hover:bg-muted text-foreground"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm">{getToolName(t, tool.id, tool.name)}</span>
{status === "not_installed" && (
<>
<Download
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.notInstalled}</span>
</>
)}
{status === "queued" && (
<>
<Clock
className="h-3.5 w-3.5 text-muted-foreground ms-auto"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.queued}</span>
</>
)}
{status === "installing" && (
<>
<Loader2
className="h-3.5 w-3.5 text-muted-foreground ms-auto animate-spin"
aria-hidden="true"
/>
<span className="sr-only">{t.a11y.installing}</span>
</>
)}
</button>
);
})}
</div>
</div>
))}
</div>
);
})}
</div>
</div>
{/* Right panel: File preview (modality-aware) */}
<div className="flex-1 flex items-center justify-center p-6 min-h-0">
{files.length > 1 ? (
<MultiImageViewer />
) : currentEntry?.previewLoading ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
<p className="text-sm text-muted-foreground">{t.homePage.generatingPreview}</p>
<p className="text-xs text-muted-foreground">{selectedFileName}</p>
</div>
) : currentEntry?.previewKind === "video" || currentEntry?.previewKind === "audio" ? (
<MediaPlayerView />
) : currentEntry?.previewKind === "document" ? (
<DocumentView />
) : currentEntry?.previewKind === "none" ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<FileArchive className="h-12 w-12 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">
{selectedFileName ?? files[0].name}
</p>
<p className="text-xs text-muted-foreground">
{selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""}
</p>
</div>
) : originalBlobUrl ? (
<ImageViewer
src={originalBlobUrl}
filename={selectedFileName ?? files[0].name}
fileSize={selectedFileSize ?? files[0].size}
/>
) : (
<div className="text-center text-muted-foreground">
<p>{t.homePage.loadingPreview}</p>
</div>
)}
</div>
<AppLayout>
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">Tool browser coming soon (Task 6)</p>
</div>
</AppLayout>
);
+14 -3
View File
@@ -1,4 +1,4 @@
import { PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import {
CheckCircle2,
ChevronLeft,
@@ -181,6 +181,17 @@ export function ToolPage() {
const { hasPermission } = useAuth();
const isAdmin = hasPermission("settings:write");
const breadcrumb = useMemo(() => {
if (!tool) return undefined;
const modalityInfo = MODALITIES.find((m) => m.id === tool.modality);
const modalityDisplay =
tool.modality === "file" ? "Data" : (modalityInfo?.name ?? tool.modality);
return {
modality: modalityDisplay,
toolName: getToolName(t, tool.id, tool.name),
};
}, [tool, t]);
useEffect(() => {
if (isAiTool) fetchFeatures();
}, [isAiTool, fetchFeatures]);
@@ -829,7 +840,7 @@ export function ToolPage() {
// Mobile layout: full-height image area with BottomSheet for settings
if (isMobile) {
return (
<AppLayout showToolPanel={false}>
<AppLayout breadcrumb={breadcrumb}>
<div className="flex flex-col w-full h-full">
{/* Tool header */}
<div className="flex items-center gap-3 p-4 border-b border-border shrink-0">
@@ -886,7 +897,7 @@ export function ToolPage() {
// Desktop layout: side-by-side
return (
<AppLayout showToolPanel={false}>
<AppLayout breadcrumb={breadcrumb}>
<div className="flex h-full w-full">
{/* Tool Settings Panel */}
<div className="settings-container w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">