mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -1,39 +1,22 @@
|
|||||||
import { Globe, Menu, X } from "lucide-react";
|
import { useState } from "react";
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
|
||||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useConnectionStore } from "@/stores/connection-store";
|
import { useConnectionStore } from "@/stores/connection-store";
|
||||||
import { Dropzone } from "../common/dropzone";
|
|
||||||
import { OtterLogo } from "../common/otter-logo";
|
|
||||||
import { HelpDialog } from "../help/help-dialog";
|
import { HelpDialog } from "../help/help-dialog";
|
||||||
import { SettingsDialog } from "../settings/settings-dialog";
|
import { SettingsDialog } from "../settings/settings-dialog";
|
||||||
import { AiInstallIndicator } from "./ai-install-indicator";
|
import { AiInstallIndicator } from "./ai-install-indicator";
|
||||||
import { Footer } from "./footer";
|
|
||||||
import { MobileBottomNav } from "./mobile-bottom-nav";
|
import { MobileBottomNav } from "./mobile-bottom-nav";
|
||||||
import { Sidebar } from "./sidebar";
|
import { TopNav } from "./top-nav.js";
|
||||||
import { ToolPanel } from "./tool-panel";
|
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
showToolPanel?: boolean;
|
breadcrumb?: { modality?: string; toolName?: string };
|
||||||
onFiles?: (files: File[]) => void;
|
navVariant?: "light" | "dark";
|
||||||
onUrlImport?: (file: File) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppLayout({
|
export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps) {
|
||||||
children,
|
|
||||||
showToolPanel = true,
|
|
||||||
onFiles,
|
|
||||||
onUrlImport,
|
|
||||||
}: AppLayoutProps) {
|
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = 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 isMobile = useMobile();
|
||||||
const connectionStatus = useConnectionStore((s) => s.status);
|
const connectionStatus = useConnectionStore((s) => s.status);
|
||||||
const bannerVisible = connectionStatus !== "connected";
|
const bannerVisible = connectionStatus !== "connected";
|
||||||
@@ -41,121 +24,26 @@ export function AppLayout({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
bannerVisible && "pt-9",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Desktop sidebar */}
|
{/* Top navigation bar */}
|
||||||
{!isMobile && (
|
<TopNav
|
||||||
<Sidebar
|
variant={navVariant}
|
||||||
onSettingsClick={() => setSettingsOpen(true)}
|
breadcrumb={breadcrumb}
|
||||||
onHelpClick={() => setHelpOpen(true)}
|
onHelpClick={() => setHelpOpen(true)}
|
||||||
/>
|
onSettingsClick={() => setSettingsOpen(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 />}
|
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
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}
|
||||||
{children || (
|
|
||||||
<Dropzone onFiles={onFiles} onUrlImport={onUrlImport} fileFilter={() => true} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{!isMobile && <Footer />}
|
|
||||||
|
|
||||||
{/* Mobile bottom nav */}
|
{/* Mobile bottom nav */}
|
||||||
{isMobile && <MobileBottomNav onSettingsClick={() => setSettingsOpen(true)} />}
|
{isMobile && <MobileBottomNav onSettingsClick={() => setSettingsOpen(true)} />}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -364,7 +364,7 @@ export function AutomatePage() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout>
|
||||||
<div className="flex flex-col h-full w-full overflow-hidden">
|
<div className="flex flex-col h-full w-full overflow-hidden">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border shrink-0">
|
<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 */
|
/* Desktop Layout */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
return (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout>
|
||||||
<div className="flex h-full w-full overflow-hidden">
|
<div className="flex h-full w-full overflow-hidden">
|
||||||
{/* LEFT PANE — Tool Palette */}
|
{/* LEFT PANE — Tool Palette */}
|
||||||
<div className="w-72 border-r border-border flex flex-col shrink-0">
|
<div className="w-72 border-r border-border flex flex-col shrink-0">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function FilesPage() {
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout>
|
||||||
<h1 className="sr-only">{t.files.myFiles}</h1>
|
<h1 className="sr-only">{t.files.myFiles}</h1>
|
||||||
<div className="flex flex-col h-full w-full overflow-hidden">
|
<div className="flex flex-col h-full w-full overflow-hidden">
|
||||||
{/* Mobile tabs */}
|
{/* Mobile tabs */}
|
||||||
@@ -95,7 +95,7 @@ export function FilesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout>
|
||||||
<h1 className="sr-only">{t.files.myFiles}</h1>
|
<h1 className="sr-only">{t.files.myFiles}</h1>
|
||||||
<div className="flex h-full w-full overflow-hidden">
|
<div className="flex h-full w-full overflow-hidden">
|
||||||
<FilesNav />
|
<FilesNav />
|
||||||
|
|||||||
@@ -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 { 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() {
|
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 (
|
return (
|
||||||
<AppLayout showToolPanel={false} onFiles={handleFiles}>
|
<AppLayout>
|
||||||
<h1 className="sr-only">{t.nav.tools}</h1>
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="flex h-full w-full">
|
<p className="text-muted-foreground">Tool browser coming soon (Task 6)</p>
|
||||||
{/* 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>
|
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -181,6 +181,17 @@ export function ToolPage() {
|
|||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const isAdmin = hasPermission("settings:write");
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAiTool) fetchFeatures();
|
if (isAiTool) fetchFeatures();
|
||||||
}, [isAiTool, fetchFeatures]);
|
}, [isAiTool, fetchFeatures]);
|
||||||
@@ -829,7 +840,7 @@ export function ToolPage() {
|
|||||||
// Mobile layout: full-height image area with BottomSheet for settings
|
// Mobile layout: full-height image area with BottomSheet for settings
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout breadcrumb={breadcrumb}>
|
||||||
<div className="flex flex-col w-full h-full">
|
<div className="flex flex-col w-full h-full">
|
||||||
{/* Tool header */}
|
{/* Tool header */}
|
||||||
<div className="flex items-center gap-3 p-4 border-b border-border shrink-0">
|
<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
|
// Desktop layout: side-by-side
|
||||||
return (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout breadcrumb={breadcrumb}>
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full">
|
||||||
{/* Tool Settings Panel */}
|
{/* Tool Settings Panel */}
|
||||||
<div className="settings-container w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">
|
<div className="settings-container w-72 border-r border-border p-4 space-y-4 overflow-y-auto shrink-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user