mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: remove /fullscreen route, add dynamic page titles
This commit is contained in:
@@ -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() {
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/fullscreen" element={<Navigate to="/" replace />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
{/* Redirects: old color tools consolidated into adjust-colors */}
|
||||
<Route
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function usePageTitle(title?: string) {
|
||||
useEffect(() => {
|
||||
document.title = title ? `${title} - SnapOtter` : "SnapOtter";
|
||||
return () => {
|
||||
document.title = "SnapOtter";
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
|
||||
const [modalityTab, setModalityTab] = useState<string>("all");
|
||||
const fetchFeatures = useFeaturesStore((s) => s.fetch);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeatures();
|
||||
}, [fetchFeatures]);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<{ settings: Record<string, string> }>("/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<string, Tool[]>();
|
||||
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<string, Modality>();
|
||||
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 (
|
||||
<div className={cn("min-h-screen bg-background text-foreground", isMobile && "pb-20")}>
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-30 bg-background/95 backdrop-blur-sm border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-lg font-bold text-foreground shrink-0"
|
||||
>
|
||||
<OtterLogo className="h-6 w-6 text-primary" />
|
||||
<span className="text-primary">SnapOtter</span>
|
||||
</Link>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toggle details */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm transition-colors",
|
||||
showDetails
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{showDetails ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">
|
||||
{showDetails ? t.fullscreenGrid.hideDetails : t.fullscreenGrid.showDetails}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Switch to sidebar view */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border text-sm text-muted-foreground hover:bg-muted transition-colors"
|
||||
title="Switch to sidebar view"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t.fullscreenGrid.sidebarButton}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Modality tabs */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MODALITY_TABS.map((tab) => {
|
||||
const TabIcon = tab.icon;
|
||||
const isActive = modalityTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setModalityTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<TabIcon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
|
||||
<h1 className="sr-only">{t.nav.tools}</h1>
|
||||
{activeCategories.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Search className="h-12 w-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="text-lg font-medium">{t.fullscreenGrid.noToolsFound}</p>
|
||||
<p className="text-sm mt-1">{t.fullscreenGrid.tryDifferent}</p>
|
||||
</div>
|
||||
) : modalitySections ? (
|
||||
/* "All" tab: render with modality section headers */
|
||||
<div className="space-y-10">
|
||||
{modalitySections.map((section) => {
|
||||
const SectionIcon = ICON_MAP[section.modality.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>;
|
||||
return (
|
||||
<section key={section.modality.id}>
|
||||
<div className="flex items-center gap-3 mb-5 pb-3 border-b border-border">
|
||||
{SectionIcon && (
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: `${section.modality.color}15`,
|
||||
color: section.modality.color,
|
||||
}}
|
||||
>
|
||||
<SectionIcon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{getModalityName(
|
||||
t,
|
||||
section.modality.id,
|
||||
section.modality.id === "document"
|
||||
? "Documents & Files"
|
||||
: section.modality.name,
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{section.categories.map((category) => (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
tools={groupedTools.get(category.id) || []}
|
||||
showDetails={showDetails}
|
||||
accentColor={section.modality.color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Specific modality tab selected */
|
||||
<div>
|
||||
{(() => {
|
||||
const activeMod = MODALITIES.find((m) => m.id === modalityTab);
|
||||
const ModIcon = activeMod
|
||||
? (ICON_MAP[activeMod.icon] as React.ComponentType<{ className?: string }>)
|
||||
: null;
|
||||
return activeMod ? (
|
||||
<div className="flex items-center gap-3 mb-6 pb-3 border-b border-border">
|
||||
{ModIcon && (
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: `${activeMod.color}15`,
|
||||
color: activeMod.color,
|
||||
}}
|
||||
>
|
||||
<ModIcon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{getModalityName(
|
||||
t,
|
||||
activeMod.id,
|
||||
activeMod.id === "document" ? "Documents & Files" : activeMod.name,
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{activeCategories.map((category) => {
|
||||
const activeMod = MODALITIES.find((m) => m.id === modalityTab);
|
||||
return (
|
||||
<CategoryCard
|
||||
key={category.id}
|
||||
category={category}
|
||||
tools={groupedTools.get(category.id) || []}
|
||||
showDetails={showDetails}
|
||||
accentColor={activeMod?.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{isMobile && <MobileBottomNav />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-xl border border-border bg-card overflow-hidden" style={undefined}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 flex items-center gap-3"
|
||||
style={{ backgroundColor: `${category.color}15` }}
|
||||
>
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${category.color}25`, color: category.color }}
|
||||
>
|
||||
<CategoryIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground text-sm">
|
||||
{getCategoryName(t, category.id, category.name)}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `${category.color}20`,
|
||||
color: category.color,
|
||||
}}
|
||||
>
|
||||
{tools.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tool list */}
|
||||
<div className="p-2">
|
||||
{tools.map((tool) => {
|
||||
const ToolIcon =
|
||||
(ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? FileImage;
|
||||
return (
|
||||
<Link
|
||||
key={tool.id}
|
||||
to={tool.route}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
||||
>
|
||||
<ToolIcon className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{getToolName(t, tool.id, tool.name)}
|
||||
</p>
|
||||
{showDetails && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{getToolDescription(t, tool.id, tool.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tool.experimental && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 font-medium shrink-0">
|
||||
{t.common.experimental}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { usePageTitle } from "@/hooks/use-page-title";
|
||||
|
||||
export function HomePage() {
|
||||
usePageTitle();
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user