diff --git a/apps/web/index.html b/apps/web/index.html index fd4126db..ae8a3fd2 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,7 +2,7 @@ - + SnapOtter diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 325a106b..d301e276 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,6 +6,7 @@ import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { I18nProvider } from "./contexts/i18n-context"; import { useAuth } from "./hooks/use-auth"; +import { useMobile } from "./hooks/use-mobile"; import { identify, initAnalytics, setAnalyticsConsent } from "./lib/analytics"; import { useAnalyticsStore } from "./stores/analytics-store"; @@ -177,6 +178,7 @@ function PageLoader() { } export function App() { + const isMobile = useMobile(); const analyticsConfig = useAnalyticsStore((s) => s.config); const analyticsConfigLoaded = useAnalyticsStore((s) => s.configLoaded); const fetchAnalyticsConfig = useAnalyticsStore((s) => s.fetchConfig); @@ -217,7 +219,7 @@ export function App() { - + diff --git a/apps/web/src/components/layout/app-layout.tsx b/apps/web/src/components/layout/app-layout.tsx index ee59228d..5e233708 100644 --- a/apps/web/src/components/layout/app-layout.tsx +++ b/apps/web/src/components/layout/app-layout.tsx @@ -1,25 +1,16 @@ -import { - FolderOpen, - Globe, - LayoutGrid, - Menu, - Settings as SettingsIcon, - Workflow, - X, -} from "lucide-react"; +import { Globe, Menu, X } from "lucide-react"; import { useState } from "react"; -import { Link } from "react-router-dom"; import { useTranslation } from "@/contexts/i18n-context"; import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { useConnectionStore } from "@/stores/connection-store"; import { Dropzone } from "../common/dropzone"; -import { ImageEditIcon } from "../common/image-edit-icon"; import { OtterLogo } from "../common/otter-logo"; import { HelpDialog } from "../help/help-dialog"; import { SettingsDialog } from "../settings/settings-dialog"; import { AiInstallIndicator } from "./ai-install-indicator"; import { Footer } from "./footer"; +import { MobileBottomNav } from "./mobile-bottom-nav"; import { Sidebar } from "./sidebar"; import { ToolPanel } from "./tool-panel"; @@ -39,7 +30,7 @@ export function AppLayout({ const [settingsOpen, setSettingsOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); - const { t, locale, setLocale, supportedLocales } = useTranslation(); + const { locale, setLocale, supportedLocales } = useTranslation(); const isMobile = useMobile(); const connectionStatus = useConnectionStore((s) => s.status); const bannerVisible = connectionStatus !== "connected"; @@ -78,9 +69,9 @@ export function AppLayout({ setMobileSidebarOpen(true)} - className="p-1.5 rounded-lg hover:bg-muted" + className="p-2.5 -ms-1 rounded-lg hover:bg-muted" > @@ -141,7 +132,7 @@ export function AppLayout({ {showToolPanel && !isMobile && } -
+
{children || }
@@ -150,22 +141,7 @@ export function AppLayout({ {!isMobile &&
} {/* Mobile bottom nav */} - {isMobile && ( - - )} + {isMobile && setSettingsOpen(true)} />} {/* Settings dialog */} setSettingsOpen(false)} /> @@ -178,23 +154,3 @@ export function AppLayout({ ); } - -function MobileNavItem({ - icon: Icon, - label, - href, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - href: string; -}) { - return ( - - - {label} - - ); -} diff --git a/apps/web/src/components/layout/mobile-bottom-nav.tsx b/apps/web/src/components/layout/mobile-bottom-nav.tsx new file mode 100644 index 00000000..f5bfb7b3 --- /dev/null +++ b/apps/web/src/components/layout/mobile-bottom-nav.tsx @@ -0,0 +1,54 @@ +import { FolderOpen, LayoutGrid, Settings as SettingsIcon, Workflow } from "lucide-react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "@/contexts/i18n-context"; +import { ImageEditIcon } from "../common/image-edit-icon"; + +interface MobileBottomNavProps { + onSettingsClick?: () => void; +} + +export function MobileBottomNav({ onSettingsClick }: MobileBottomNavProps) { + const { t } = useTranslation(); + + return ( + + ); +} + +function MobileNavItem({ + icon: Icon, + label, + href, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + href: string; +}) { + return ( + + + {label} + + ); +} diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index d33f4ac8..a7f43cdd 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -29,6 +29,7 @@ import { import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "@/contexts/i18n-context"; import { useAuth } from "@/hooks/use-auth"; +import { useMobile } from "@/hooks/use-mobile"; import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@/lib/api"; import { format, plural } from "@/lib/format"; import { getCategoryName, getToolDescription, getToolName } from "@/lib/tool-i18n"; @@ -124,6 +125,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { const [section, setSection] = useState
("general"); const { hasPermission, authEnabled } = useAuth(); const { t } = useTranslation(); + const isMobile = useMobile(); const NAV_ITEMS = useNavItems(); const visibleNavItems = NAV_ITEMS.filter( @@ -144,6 +146,60 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) { if (!open) return null; + if (isMobile) { + return ( +
+ {/* Mobile header */} +
+

{t.settings.heading}

+ +
+ + {/* Mobile pill strip nav */} +
+ {visibleNavItems.map((item) => ( + + ))} +
+ + {/* Mobile content */} +
+ {section === "general" && } + {section === "system" && } + {section === "security" && } + {section === "people" && } + {section === "teams" && } + {section === "roles" && } + {section === "audit-log" && } + {section === "api-keys" && } + {section === "ai-features" && } + {section === "tools" && } + {section === "analytics" && } + {section === "about" && } +
+
+ ); + } + return (
{/* Backdrop */} @@ -249,7 +305,7 @@ interface UserEntry { } interface TeamEntry { - id: number; + id: string; name: string; memberCount: number; createdAt: string; @@ -624,6 +680,7 @@ function SecuritySection() { const [confirmPassword, setConfirmPassword] = useState(""); const [showCurrent, setShowCurrent] = useState(false); const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); @@ -709,14 +766,24 @@ function SecuritySection() {
- setConfirmPassword(e.target.value)} - placeholder={t.settings.security.confirmPasswordPlaceholder} - className="w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground" - required - /> +
+ setConfirmPassword(e.target.value)} + placeholder={t.settings.security.confirmPasswordPlaceholder} + className="w-full px-3 py-2 pe-10 rounded-lg border border-border bg-background text-sm text-foreground" + required + /> + +
{message && (

([]); const [maxUsers, setMaxUsers] = useState(5); const [loading, setLoading] = useState(true); @@ -1029,7 +1097,7 @@ function PeopleSection() {

{t.settings.people.newMemberHeading}

-
+
- {/* Table header */} -
- {t.settings.people.tableHeaderUser} - {t.settings.people.tableHeaderRole} - {t.settings.people.tableHeaderTeam} - -
+ {/* Table header (desktop only) */} + {!isMobile && ( +
+ {t.settings.people.tableHeaderUser} + {t.settings.people.tableHeaderRole} + {t.settings.people.tableHeaderTeam} + +
+ )} {/* Table rows */} {filteredUsers.length === 0 ? ( @@ -1277,45 +1347,91 @@ function PeopleSection() { filteredUsers.map((u) => (
- {/* User cell */} -
-
- {u.username.charAt(0).toUpperCase()} -
- {u.username} - {u.hasOidcLink && u.hasLocalPassword !== false && ( - - {t.auth.methodBoth} - - )} - {u.hasOidcLink && u.hasLocalPassword === false && ( - - {t.auth.methodOidc} - - )} -
+ {isMobile ? ( + <> + {/* Mobile card layout */} +
+ {u.username.charAt(0).toUpperCase()} +
+
+
+ + {u.username} + + {u.hasOidcLink && u.hasLocalPassword !== false && ( + + {t.auth.methodBoth} + + )} + {u.hasOidcLink && u.hasLocalPassword === false && ( + + {t.auth.methodOidc} + + )} +
+
+ + {u.role} + + {u.team} +
+
+ + ) : ( + <> + {/* Desktop row layout */} +
+
+ {u.username.charAt(0).toUpperCase()} +
+ + {u.username} + + {u.hasOidcLink && u.hasLocalPassword !== false && ( + + {t.auth.methodBoth} + + )} + {u.hasOidcLink && u.hasLocalPassword === false && ( + + {t.auth.methodOidc} + + )} +
- {/* Role badge */} -
- - {u.role} - -
+ {/* Role badge */} +
+ + {u.role} + +
- {/* Team */} - {u.team} + {/* Team */} + {u.team} + + )} {/* Actions */} -
+
)} @@ -2758,13 +2929,19 @@ function SettingRow({ description: string; children: React.ReactNode; }) { + const isMobile = useMobile(); return ( -
+

{label}

{description}

-
{children}
+
{children}
); } diff --git a/apps/web/src/hooks/use-mobile.ts b/apps/web/src/hooks/use-mobile.ts index d5385a71..816af4df 100644 --- a/apps/web/src/hooks/use-mobile.ts +++ b/apps/web/src/hooks/use-mobile.ts @@ -9,12 +9,13 @@ const MOBILE_BREAKPOINT = 768; export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; - return window.matchMedia(query).matches; + return window.matchMedia(query)?.matches ?? false; }); useEffect(() => { if (typeof window.matchMedia !== "function") return; const mq = window.matchMedia(query); + if (!mq) return; const handler = (e: MediaQueryListEvent) => setMatches(e.matches); mq.addEventListener("change", handler); setMatches(mq.matches); diff --git a/apps/web/src/pages/fullscreen-grid-page.tsx b/apps/web/src/pages/fullscreen-grid-page.tsx index e43a139c..7982deed 100644 --- a/apps/web/src/pages/fullscreen-grid-page.tsx +++ b/apps/web/src/pages/fullscreen-grid-page.tsx @@ -4,7 +4,9 @@ import { Eye, EyeOff, FileImage, LayoutGrid, List, Search } 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 { useMobile } from "@/hooks/use-mobile"; import { track } from "@/lib/analytics"; import { apiGet } from "@/lib/api"; import { ICON_MAP } from "@/lib/icon-map"; @@ -14,6 +16,7 @@ import { useFeaturesStore } from "@/stores/features-store"; export function FullscreenGridPage() { const { t } = useTranslation(); + const isMobile = useMobile(); const [search, setSearch] = useState(""); const [showDetails, setShowDetails] = useState(true); const navigate = useNavigate(); @@ -79,7 +82,7 @@ export function FullscreenGridPage() { const activeCategories = CATEGORIES.filter((cat) => groupedTools.has(cat.id)); return ( -
+
{/* Top bar */}
@@ -154,6 +157,7 @@ export function FullscreenGridPage() {
)}
+ {isMobile && } ); } diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 4be11bbd..1775c15b 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -6,6 +6,7 @@ import { ImageViewer } from "@/components/common/image-viewer"; import { MultiImageViewer } from "@/components/common/multi-image-viewer"; import { AppLayout } from "@/components/layout/app-layout"; import { useTranslation } from "@/contexts/i18n-context"; +import { useMobile } from "@/hooks/use-mobile"; import { ICON_MAP } from "@/lib/icon-map"; import { getCategoryName, getToolName } from "@/lib/tool-i18n"; import { useFeaturesStore } from "@/stores/features-store"; @@ -32,6 +33,7 @@ export function HomePage() { 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) { @@ -93,12 +95,96 @@ export function HomePage() { return ; } - // File uploaded — show tool selector on left, image preview on right + // File uploaded — mobile: stacked layout + if (isMobile && hasFile) { + return ( + +
+ {/* File info bar */} +
+ + + {selectedFileName ?? files[0].name} + + + {selectedFileSize ? `${(selectedFileSize / 1024).toFixed(1)} KB` : ""} + + +
+ + {/* Quick action buttons - horizontal scroll */} +
+ {QUICK_ACTION_IDS.map((id) => { + const tool = TOOLS.find((t) => t.id === id); + if (!tool) return null; + const Icon = + (ICON_MAP[tool.icon] as React.ComponentType<{ className?: string }>) ?? + ICON_MAP.FileImage; + const status = getToolStatus(id); + return ( + + ); + })} +
+ + {/* Full-width image preview */} +
+ {files.length > 1 ? ( + + ) : currentEntry?.previewLoading ? ( +
+ +

{t.homePage.generatingPreview}

+

{selectedFileName}

+
+ ) : originalBlobUrl ? ( + + ) : ( +
+

{t.homePage.loadingPreview}

+
+ )} +
+
+
+ ); + } + + // File uploaded — desktop: tool selector on left, image preview on right return (
{/* Left panel: Tool selector */} -
+
{/* File info */}
diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index d5b9a7e4..0e830950 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -9,7 +9,7 @@ import { } from "lucide-react"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Crop } from "react-image-crop"; -import { useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { BottomSheet } from "@/components/common/bottom-sheet"; import { Dropzone } from "@/components/common/dropzone"; @@ -347,8 +347,14 @@ export function ToolPage() { if (!tool || !registryEntry) { return ( -
- {t.toolPage.notFound} +
+

{t.toolPage.notFound}

+ + {t.common.goHome} +
);