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:
Siddharth Kumar Sah
2026-03-22 04:42:31 +08:00
parent 6d67b106ca
commit 580208de83
5 changed files with 271 additions and 17 deletions
+12 -5
View File
@@ -2,15 +2,22 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import { HomePage } from "./pages/home-page"; import { HomePage } from "./pages/home-page";
import { LoginPage } from "./pages/login-page"; import { LoginPage } from "./pages/login-page";
import { ToolPage } from "./pages/tool-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() { export function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <KeyboardShortcutProvider>
<Route path="/login" element={<LoginPage />} /> <Routes>
<Route path="/:toolId" element={<ToolPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/automate" element={<AutomatePage />} />
</Routes> <Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</KeyboardShortcutProvider>
</BrowserRouter> </BrowserRouter>
); );
} }
+124 -8
View File
@@ -1,8 +1,20 @@
import { useState } from "react"; 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 { Sidebar } from "./sidebar";
import { ToolPanel } from "./tool-panel"; import { ToolPanel } from "./tool-panel";
import { Footer } from "./footer"; import { Footer } from "./footer";
import { Dropzone } from "../common/dropzone"; import { Dropzone } from "../common/dropzone";
import { SettingsDialog } from "../settings/settings-dialog";
import { useMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
interface AppLayoutProps { interface AppLayoutProps {
children?: React.ReactNode; children?: React.ReactNode;
@@ -10,21 +22,125 @@ interface AppLayoutProps {
} }
export function AppLayout({ children, showToolPanel = true }: 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 ( return (
<div className="flex h-screen bg-background text-foreground overflow-hidden"> <div className="flex h-screen bg-background text-foreground overflow-hidden">
<Sidebar onSettingsClick={() => setSettingsOpen(true)} /> {/* Desktop sidebar */}
{showToolPanel && <ToolPanel />} {!isMobile && (
<main className="flex-1 flex flex-col overflow-hidden"> <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"> <div className="flex-1 overflow-y-auto p-6 flex items-center justify-center">
{children || <Dropzone />} {children || <Dropzone />}
</div> </div>
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border"> {!isMobile && (
Privacy Policy <div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
</div> Privacy Policy
</div>
)}
</main> </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> </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>
);
}
+30 -3
View File
@@ -7,6 +7,7 @@ import {
FolderOpen, FolderOpen,
HelpCircle, HelpCircle,
Settings, Settings,
Grid3x3,
} from "lucide-react"; } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
@@ -18,7 +19,7 @@ interface SidebarItem {
const topItems: SidebarItem[] = [ const topItems: SidebarItem[] = [
{ icon: LayoutGrid, label: "Tools", href: "/" }, { icon: LayoutGrid, label: "Tools", href: "/" },
{ icon: BookOpen, label: "Reader", href: "/reader" }, { icon: Grid3x3, label: "Grid", href: "/fullscreen" },
{ icon: Workflow, label: "Automate", href: "/automate" }, { icon: Workflow, label: "Automate", href: "/automate" },
{ icon: FolderOpen, label: "Files", href: "/files" }, { icon: FolderOpen, label: "Files", href: "/files" },
]; ];
@@ -30,13 +31,27 @@ const bottomItems: SidebarItem[] = [
interface SidebarProps { interface SidebarProps {
onSettingsClick: () => void; 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 location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => { 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 <div
className={cn( className={cn(
"flex flex-col items-center gap-1 p-2 rounded-lg cursor-pointer transition-colors", "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 ( return (
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0"> <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"> <div className="flex flex-col gap-1 flex-1">
+22
View File
@@ -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;
}
+83 -1
View File
@@ -1,10 +1,11 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useMemo, useCallback } from "react"; import { useMemo, useCallback, useState } from "react";
import { TOOLS } from "@stirling-image/shared"; import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone"; import { Dropzone } from "@/components/common/dropzone";
import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BeforeAfterSlider } from "@/components/common/before-after-slider";
import { useFileStore } from "@/stores/file-store"; import { useFileStore } from "@/stores/file-store";
import { useMobile } from "@/hooks/use-mobile";
import { ResizeSettings } from "@/components/tools/resize-settings"; import { ResizeSettings } from "@/components/tools/resize-settings";
import { CropSettings } from "@/components/tools/crop-settings"; import { CropSettings } from "@/components/tools/crop-settings";
import { RotateSettings } from "@/components/tools/rotate-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 { EraseObjectSettings } from "@/components/tools/erase-object-settings";
import { SmartCropSettings } from "@/components/tools/smart-crop-settings"; import { SmartCropSettings } from "@/components/tools/smart-crop-settings";
import * as icons from "lucide-react"; import * as icons from "lucide-react";
import { cn } from "@/lib/utils";
const COLOR_TOOL_IDS = new Set([ const COLOR_TOOL_IDS = new Set([
"brightness-contrast", "brightness-contrast",
@@ -111,6 +113,8 @@ export function ToolPage() {
const { toolId } = useParams<{ toolId: string }>(); const { toolId } = useParams<{ toolId: string }>();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]); const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore(); const { files, setFiles, reset, processedUrl, originalBlobUrl, originalSize, processedSize } = useFileStore();
const isMobile = useMobile();
const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true);
const handleFiles = useCallback( const handleFiles = useCallback(
(newFiles: File[]) => { (newFiles: File[]) => {
@@ -141,6 +145,84 @@ export function ToolPage() {
const hasFile = files.length > 0; const hasFile = files.length > 0;
const isNoDropzone = NO_DROPZONE_TOOLS.has(tool.id); 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 ( return (
<AppLayout showToolPanel={false}> <AppLayout showToolPanel={false}>
<div className="flex h-full w-full"> <div className="flex h-full w-full">