mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): add mobile responsive layout with bottom navigation
Add useMobile hook for viewport detection (<768px). Update AppLayout with hamburger menu, slide-over sidebar overlay, and fixed bottom navigation bar (Tools, Automate, Files, Settings) for mobile. Update Sidebar with expanded mode for mobile overlay. Update ToolPage to stack settings above dropzone on mobile with collapsible panel. Add new routes for /automate and /fullscreen. Integrate Settings dialog and keyboard shortcuts at the App root level.
This commit is contained in:
+12
-5
@@ -2,15 +2,22 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { HomePage } from "./pages/home-page";
|
||||
import { LoginPage } from "./pages/login-page";
|
||||
import { ToolPage } from "./pages/tool-page";
|
||||
import { AutomatePage } from "./pages/automate-page";
|
||||
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
<KeyboardShortcutProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/automate" element={<AutomatePage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
</KeyboardShortcutProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Workflow,
|
||||
FolderOpen,
|
||||
Settings as SettingsIcon,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { ToolPanel } from "./tool-panel";
|
||||
import { Footer } from "./footer";
|
||||
import { Dropzone } from "../common/dropzone";
|
||||
import { SettingsDialog } from "../settings/settings-dialog";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -10,21 +22,125 @@ interface AppLayoutProps {
|
||||
}
|
||||
|
||||
export function AppLayout({ children, showToolPanel = true }: AppLayoutProps) {
|
||||
const [_settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const isMobile = useMobile();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background text-foreground overflow-hidden">
|
||||
<Sidebar onSettingsClick={() => setSettingsOpen(true)} />
|
||||
{showToolPanel && <ToolPanel />}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Desktop sidebar */}
|
||||
{!isMobile && (
|
||||
<Sidebar onSettingsClick={() => setSettingsOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar overlay */}
|
||||
{isMobile && mobileSidebarOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<div 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">
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
Stirling <span className="text-primary">Image</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-muted"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div onClick={() => setMobileSidebarOpen(false)}>
|
||||
<Sidebar
|
||||
onSettingsClick={() => {
|
||||
setMobileSidebarOpen(false);
|
||||
setSettingsOpen(true);
|
||||
}}
|
||||
expanded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile top bar */}
|
||||
{isMobile && (
|
||||
<div className="fixed top-0 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">
|
||||
<button
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
className="p-1.5 rounded-lg hover:bg-muted"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="text-sm font-bold text-foreground">
|
||||
Stirling <span className="text-primary">Image</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showToolPanel && !isMobile && <ToolPanel />}
|
||||
|
||||
<main
|
||||
className={cn(
|
||||
"flex-1 flex flex-col overflow-hidden",
|
||||
isMobile && "pt-12 pb-16"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
|
||||
{children || <Dropzone />}
|
||||
</div>
|
||||
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
|
||||
Privacy Policy
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
|
||||
Privacy Policy
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{!isMobile && <Footer />}
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
{isMobile && (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-background/95 backdrop-blur-sm border-t border-border flex items-center justify-around px-2 py-1.5">
|
||||
<MobileNavItem icon={LayoutGrid} label="Tools" href="/" />
|
||||
<MobileNavItem icon={Workflow} label="Automate" href="/automate" />
|
||||
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground"
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span className="text-[10px]">Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
href,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="flex flex-col items-center gap-0.5 px-3 py-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
FolderOpen,
|
||||
HelpCircle,
|
||||
Settings,
|
||||
Grid3x3,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
@@ -18,7 +19,7 @@ interface SidebarItem {
|
||||
|
||||
const topItems: SidebarItem[] = [
|
||||
{ icon: LayoutGrid, label: "Tools", href: "/" },
|
||||
{ icon: BookOpen, label: "Reader", href: "/reader" },
|
||||
{ icon: Grid3x3, label: "Grid", href: "/fullscreen" },
|
||||
{ icon: Workflow, label: "Automate", href: "/automate" },
|
||||
{ icon: FolderOpen, label: "Files", href: "/files" },
|
||||
];
|
||||
@@ -30,13 +31,27 @@ const bottomItems: SidebarItem[] = [
|
||||
|
||||
interface SidebarProps {
|
||||
onSettingsClick: () => void;
|
||||
/** When true, renders in expanded mode (for mobile overlay). */
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ onSettingsClick }: SidebarProps) {
|
||||
export function Sidebar({ onSettingsClick, expanded = false }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
const renderItem = (item: SidebarItem, isActive: boolean) => {
|
||||
const content = (
|
||||
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",
|
||||
@@ -64,6 +79,18 @@ export function Sidebar({ onSettingsClick }: SidebarProps) {
|
||||
);
|
||||
};
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div 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))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/**
|
||||
* Returns true when the viewport width is below the mobile breakpoint (768px).
|
||||
*/
|
||||
export function useMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth < MOBILE_BREAKPOINT : false
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
setIsMobile(mq.matches);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useMobile } from "@/hooks/use-mobile";
|
||||
import { ResizeSettings } from "@/components/tools/resize-settings";
|
||||
import { CropSettings } from "@/components/tools/crop-settings";
|
||||
import { RotateSettings } from "@/components/tools/rotate-settings";
|
||||
@@ -46,6 +47,7 @@ import { BlurFacesSettings } from "@/components/tools/blur-faces-settings";
|
||||
import { EraseObjectSettings } from "@/components/tools/erase-object-settings";
|
||||
import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
|
||||
import * as icons from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const COLOR_TOOL_IDS = new Set([
|
||||
"brightness-contrast",
|
||||
@@ -111,6 +113,8 @@ export function ToolPage() {
|
||||
const { toolId } = useParams<{ toolId: string }>();
|
||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
||||
const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore();
|
||||
const isMobile = useMobile();
|
||||
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
@@ -141,6 +145,84 @@ export function ToolPage() {
|
||||
const hasFile = files.length > 0;
|
||||
const isNoDropzone = NO_DROPZONE_TOOLS.has(tool.id);
|
||||
|
||||
// Mobile layout: settings above dropzone (stacked)
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<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">
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground flex-1">
|
||||
{tool.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setMobileSettingsOpen(!mobileSettingsOpen)}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
{mobileSettingsOpen ? "Hide Settings" : "Settings"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsible settings */}
|
||||
{mobileSettingsOpen && (
|
||||
<div className="p-4 border-b border-border space-y-3 shrink-0 max-h-[40vh] overflow-y-auto">
|
||||
{/* File info */}
|
||||
{!isNoDropzone && hasFile && (
|
||||
<div className="space-y-1">
|
||||
{files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-xs text-foreground bg-muted rounded px-2 py-1"
|
||||
>
|
||||
<span className="truncate">{f.name}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">
|
||||
{(f.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<ToolSettingsPanel toolId={tool.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropzone / Preview */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
{isNoDropzone ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">Configure settings and generate.</p>
|
||||
</div>
|
||||
) : processedUrl && originalBlobUrl ? (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
afterSrc={processedUrl}
|
||||
beforeSize={originalSize ?? undefined}
|
||||
afterSize={processedSize ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple
|
||||
currentFiles={files}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout: side-by-side
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
<div className="flex h-full w-full">
|
||||
|
||||
Reference in New Issue
Block a user