mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): redesign home page upload flow and add auth guard
Home page: after uploading an image, shows tool selector on the left (quick actions + all 37 tools by category) with image preview on the right. Stays on the main page — no popup overlay. Auth: when AUTH_ENABLED=true, unauthenticated users are redirected to /login. Default credentials admin/admin. When auth is disabled (dev default), no redirect happens.
This commit is contained in:
+38
-8
@@ -1,22 +1,52 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate, useLocation } 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 { AutomatePage } from "./pages/automate-page";
|
||||||
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
import { FullscreenGridPage } from "./pages/fullscreen-grid-page";
|
||||||
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
|
||||||
|
import { useAuth } from "./hooks/use-auth";
|
||||||
|
|
||||||
|
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { loading, authEnabled, isAuthenticated } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Don't guard the login page itself
|
||||||
|
if (location.pathname === "/login") {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authEnabled && !isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<KeyboardShortcutProvider>
|
<KeyboardShortcutProvider>
|
||||||
<Routes>
|
<AuthGuard>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Routes>
|
||||||
<Route path="/automate" element={<AutomatePage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
<Route path="/automate" element={<AutomatePage />} />
|
||||||
<Route path="/:toolId" element={<ToolPage />} />
|
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/:toolId" element={<ToolPage />} />
|
||||||
</Routes>
|
<Route path="/" element={<HomePage />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthGuard>
|
||||||
</KeyboardShortcutProvider>
|
</KeyboardShortcutProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
loading: boolean;
|
||||||
|
authEnabled: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthState {
|
||||||
|
const [state, setState] = useState<AuthState>({
|
||||||
|
loading: true,
|
||||||
|
authEnabled: false,
|
||||||
|
isAuthenticated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
// Check if auth is enabled
|
||||||
|
const configRes = await fetch("/api/v1/config/auth");
|
||||||
|
const config = await configRes.json();
|
||||||
|
|
||||||
|
if (!config.authEnabled) {
|
||||||
|
setState({ loading: false, authEnabled: false, isAuthenticated: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is enabled — check if we have a valid session
|
||||||
|
const token = localStorage.getItem("stirling-token");
|
||||||
|
if (!token) {
|
||||||
|
setState({ loading: false, authEnabled: true, isAuthenticated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionRes = await fetch("/api/auth/session", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionRes.ok) {
|
||||||
|
setState({ loading: false, authEnabled: true, isAuthenticated: true });
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("stirling-token");
|
||||||
|
setState({ loading: false, authEnabled: true, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Can't reach API — assume no auth needed (dev mode)
|
||||||
|
setState({ loading: false, authEnabled: false, isAuthenticated: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -1,87 +1,143 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
|
import { ImageViewer } from "@/components/common/image-viewer";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import {
|
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
|
||||||
Maximize2,
|
import * as icons from "lucide-react";
|
||||||
Minimize2,
|
import { cn } from "@/lib/utils";
|
||||||
FileOutput,
|
|
||||||
Eraser,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
const QUICK_ACTIONS = [
|
// Tools shown prominently as "quick actions" at the top
|
||||||
{ id: "resize", name: "Resize", icon: Maximize2, route: "/resize" },
|
const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
|
||||||
{ id: "compress", name: "Compress", icon: Minimize2, route: "/compress" },
|
|
||||||
{ id: "convert", name: "Convert", icon: FileOutput, route: "/convert" },
|
|
||||||
{ id: "remove-background", name: "Remove Background", icon: Eraser, route: "/remove-background" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { setFiles, files, reset } = useFileStore();
|
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } = useFileStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showActions, setShowActions] = useState(false);
|
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
(newFiles: File[]) => {
|
(newFiles: File[]) => {
|
||||||
reset();
|
reset();
|
||||||
setFiles(newFiles);
|
setFiles(newFiles);
|
||||||
setShowActions(true);
|
|
||||||
},
|
},
|
||||||
[setFiles, reset],
|
[setFiles, reset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAction = (route: string) => {
|
const handleToolClick = (route: string) => {
|
||||||
setShowActions(false);
|
// Files are already in the store — just navigate
|
||||||
navigate(route);
|
navigate(route);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const hasFile = files.length > 0;
|
||||||
setShowActions(false);
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// If no file uploaded, show default layout (tool panel + dropzone)
|
||||||
|
if (!hasFile) {
|
||||||
|
return <AppLayout onFiles={handleFiles} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File uploaded — show tool selector on left, image preview on right
|
||||||
return (
|
return (
|
||||||
<AppLayout onFiles={handleFiles}>
|
<AppLayout showToolPanel={false} onFiles={handleFiles}>
|
||||||
{/* Quick-action overlay */}
|
<div className="flex h-full w-full">
|
||||||
{showActions && files.length > 0 && (
|
{/* Left panel: Tool selector */}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="w-80 border-r border-border overflow-y-auto shrink-0">
|
||||||
<div className="bg-background rounded-2xl shadow-2xl border border-border p-6 max-w-md w-full mx-4 animate-in fade-in zoom-in-95 duration-200">
|
{/* File info */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="p-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
What would you like to do?
|
<icons.CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||||
</h2>
|
<span className="truncate font-medium text-foreground">
|
||||||
<button
|
{selectedFileName ?? files[0].name}
|
||||||
onClick={handleDismiss}
|
</span>
|
||||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
{selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""}
|
||||||
<span className="font-medium text-foreground">{files[0].name}</span>
|
{files.length > 1 && ` — ${files.length} files`}
|
||||||
{" "}({(files[0].size / 1024).toFixed(1)} KB)
|
|
||||||
{files.length > 1 && ` +${files.length - 1} more`}
|
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground mt-2"
|
||||||
|
>
|
||||||
|
Change file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* Quick actions */}
|
||||||
{QUICK_ACTIONS.map(({ id, name, icon: Icon, route }) => (
|
<div className="p-4 border-b border-border">
|
||||||
<button
|
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||||
key={id}
|
Quick Actions
|
||||||
onClick={() => handleAction(route)}
|
</h3>
|
||||||
className="flex items-center gap-3 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
<div className="grid grid-cols-2 gap-2">
|
||||||
>
|
{QUICK_ACTION_IDS.map((id) => {
|
||||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
const tool = TOOLS.find((t) => t.id === id);
|
||||||
<Icon className="h-5 w-5" />
|
if (!tool) return null;
|
||||||
</div>
|
const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
|
||||||
<span className="text-sm font-medium text-foreground">{name}</span>
|
return (
|
||||||
</button>
|
<button
|
||||||
))}
|
key={id}
|
||||||
|
onClick={() => handleToolClick(tool.route)}
|
||||||
|
className="flex items-center gap-2 p-3 rounded-xl border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="p-1.5 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-foreground">{tool.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* All tools by category */}
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-3">
|
||||||
|
All Tools
|
||||||
|
</h3>
|
||||||
|
{CATEGORIES.map((category) => {
|
||||||
|
const categoryTools = TOOLS.filter((t) => t.category === category.id);
|
||||||
|
if (categoryTools.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={category.id} className="mb-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-1.5" style={{ color: category.color }}>
|
||||||
|
{category.name}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{categoryTools.map((tool) => {
|
||||||
|
const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tool.id}
|
||||||
|
onClick={() => handleToolClick(tool.route)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 w-full py-1.5 px-2 rounded-lg text-left transition-colors",
|
||||||
|
"hover:bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm">{tool.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Right panel: Image preview */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
|
{originalBlobUrl ? (
|
||||||
|
<ImageViewer
|
||||||
|
src={originalBlobUrl}
|
||||||
|
filename={selectedFileName ?? files[0].name}
|
||||||
|
fileSize={selectedFileSize ?? files[0].size}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<p>Loading preview...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user