From df851b34978700c6d3222c1307209ed84c186962 Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Tue, 19 May 2026 09:27:36 -0600 Subject: [PATCH] Made the UI mobile friendly, finally... --- ui/package-lock.json | 15 ++++ ui/package.json | 1 + ui/src/app/dashboard/page.tsx | 2 +- ui/src/app/datasets/[datasetName]/page.tsx | 18 +++-- ui/src/app/datasets/page.tsx | 7 +- ui/src/app/globals.css | 15 ++++ ui/src/app/jobs/[jobID]/page.tsx | 16 ++-- ui/src/app/jobs/new/page.tsx | 42 +++++++---- ui/src/app/jobs/page.tsx | 10 ++- ui/src/app/layout.tsx | 6 ++ ui/src/app/settings/page.tsx | 2 +- ui/src/components/AutoCaptionButton.tsx | 27 ++++--- ui/src/components/GPUMonitor.tsx | 14 ++-- ui/src/components/JobActionBar.tsx | 37 ++++----- ui/src/components/JobOverview.tsx | 6 +- ui/src/components/JobsTable.tsx | 16 ++-- ui/src/components/Modal.tsx | 10 +-- ui/src/components/SampleImageViewer.tsx | 87 +++++++++++++++++++--- ui/src/components/SampleImages.tsx | 19 +++-- ui/src/components/Sidebar.tsx | 70 +++++++++++++++-- ui/src/components/layout.tsx | 21 +++++- version.py | 2 +- 22 files changed, 325 insertions(+), 118 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index db13014f..4b53eba3 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "react-global-hooks": "^1.3.5", "react-icons": "^5.5.0", "react-select": "^5.10.1", + "react-zoom-pan-pinch": "^4.0.3", "sqlite3": "^5.1.7", "systeminformation": "^5.27.11", "uplot": "^1.6.32", @@ -5166,6 +5167,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-zoom-pan-pinch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.3.tgz", + "integrity": "sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index e1a828b7..af33b910 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,7 @@ "react-global-hooks": "^1.3.5", "react-icons": "^5.5.0", "react-select": "^5.10.1", + "react-zoom-pan-pinch": "^4.0.3", "sqlite3": "^5.1.7", "systeminformation": "^5.27.11", "uplot": "^1.6.32", diff --git a/ui/src/app/dashboard/page.tsx b/ui/src/app/dashboard/page.tsx index bb644a5a..8685a106 100644 --- a/ui/src/app/dashboard/page.tsx +++ b/ui/src/app/dashboard/page.tsx @@ -10,7 +10,7 @@ export default function Dashboard() { <>
-

Dashboard

+

Dashboard

diff --git a/ui/src/app/datasets/[datasetName]/page.tsx b/ui/src/app/datasets/[datasetName]/page.tsx index 71a49ffd..9deb7c77 100644 --- a/ui/src/app/datasets/[datasetName]/page.tsx +++ b/ui/src/app/datasets/[datasetName]/page.tsx @@ -100,25 +100,29 @@ export default function DatasetPage({ params }: { params: { datasetName: string <> {/* Fixed top bar */} -
-
-
-

Dataset: {datasetName}

+
+

+ Dataset: + {datasetName} +

-
+
diff --git a/ui/src/app/datasets/page.tsx b/ui/src/app/datasets/page.tsx index cb10e8f6..9003dabe 100644 --- a/ui/src/app/datasets/page.tsx +++ b/ui/src/app/datasets/page.tsx @@ -114,15 +114,16 @@ export default function Datasets() { <>
-

Datasets

+

Datasets

diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 7779861a..c69a94c3 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -43,6 +43,21 @@ body { font-family: Arial, Helvetica, sans-serif; } +/* Prevent iOS Safari from auto-zooming when a form field is focused. + iOS only zooms when the field's font-size is < 16px. */ +@media (max-width: 639px) { + input, + textarea, + select, + .aitk-react-select-container .aitk-react-select__control, + .aitk-react-select-container .aitk-react-select__input, + .aitk-react-select-container .aitk-react-select__single-value, + .aitk-react-select-container .aitk-react-select__placeholder, + .aitk-react-select-container .aitk-react-select__option { + font-size: 16px !important; + } +} + @layer components { /* control */ .aitk-react-select-container .aitk-react-select__control { diff --git a/ui/src/app/jobs/[jobID]/page.tsx b/ui/src/app/jobs/[jobID]/page.tsx index f257eb90..07a05264 100644 --- a/ui/src/app/jobs/[jobID]/page.tsx +++ b/ui/src/app/jobs/[jobID]/page.tsx @@ -79,13 +79,13 @@ export default function JobPage({ params }: { params: { jobID: string } }) { <> {/* Fixed top bar */} -
-
-
-

{title}

+
+

{title}

{job && ( @@ -112,7 +112,7 @@ export default function JobPage({ params }: { params: { jobID: string } }) { )} -
+
{pages.map(page => { if (page.jobTypes && !page.jobTypes.includes(jobType)) { return null; @@ -121,16 +121,16 @@ export default function JobPage({ params }: { params: { jobID: string } }) { ); })} {page?.menuItem && ( <> -
+
)} diff --git a/ui/src/app/jobs/new/page.tsx b/ui/src/app/jobs/new/page.tsx index 71a616d5..d41808fb 100644 --- a/ui/src/app/jobs/new/page.tsx +++ b/ui/src/app/jobs/new/page.tsx @@ -190,36 +190,38 @@ export default function TrainingForm() { return ( <> -
-
-
-

{runId ? 'Edit Training Job' : 'New Training Job'}

+
+

+ {runId ? 'Edit Training Job' : 'New Training Job'} +

{showAdvancedView && ( <> -
+
setGpuIDs(value)} options={gpuList.map((gpu: any) => ({ value: `${gpu.index}`, label: `GPU #${gpu.index}` }))} />
-
-
+
+
-
+
)} {!showAdvancedView && ( <> -
+
{ @@ -246,25 +248,33 @@ export default function TrainingForm() { options={jobTypeOptions} />
-
+
)} -
+
-
+
diff --git a/ui/src/app/jobs/page.tsx b/ui/src/app/jobs/page.tsx index 817ad92a..57042706 100644 --- a/ui/src/app/jobs/page.tsx +++ b/ui/src/app/jobs/page.tsx @@ -9,12 +9,16 @@ export default function Dashboard() { <>
-

Queue

+

Queue

- - New Training Job + + + New Job + New Training Job
diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 46747e88..341ac545 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -20,6 +20,12 @@ export const metadata: Metadata = { description: 'A toolkit for building AI things.', }; +export const viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 5, +}; + export default function RootLayout({ children }: { children: React.ReactNode }) { // Check if the AI_TOOLKIT_AUTH environment variable is set const authRequired = process.env.AI_TOOLKIT_AUTH ? true : false; diff --git a/ui/src/app/settings/page.tsx b/ui/src/app/settings/page.tsx index 4bf257c3..8b0d8801 100644 --- a/ui/src/app/settings/page.tsx +++ b/ui/src/app/settings/page.tsx @@ -36,7 +36,7 @@ export default function Settings() { <>
-

Settings

+

Settings

diff --git a/ui/src/components/AutoCaptionButton.tsx b/ui/src/components/AutoCaptionButton.tsx index 207ba5c1..e9ee8903 100644 --- a/ui/src/components/AutoCaptionButton.tsx +++ b/ui/src/components/AutoCaptionButton.tsx @@ -1,4 +1,4 @@ -import React, {use, useEffect} from 'react'; +import React, { use, useEffect } from 'react'; import { Button } from '@headlessui/react'; import { CaptionDatasetModal, openCaptionDatasetModal } from '@/components/CaptionDatasetModal'; import useJobByRef from '@/hooks/useJobByRef'; @@ -14,26 +14,33 @@ export default function AutoCaptionButton({ datasetPath, setIsAutoCaptioning }: const { job, status, refreshJob } = useJobByRef(datasetPath, 5000); useEffect(() => { if (setIsAutoCaptioning) { - setIsAutoCaptioning(!!(job && (job.status === 'running'))); + setIsAutoCaptioning(!!(job && job.status === 'running')); } }, [job, setIsAutoCaptioning]); - + if (job && (job.status === 'running' || job.status === 'queued')) { return ( - + - Auto Captioning... + Auto Captioning... + Captioning ); } return ( ); } diff --git a/ui/src/components/GPUMonitor.tsx b/ui/src/components/GPUMonitor.tsx index 145ff509..fe1d7ade 100644 --- a/ui/src/components/GPUMonitor.tsx +++ b/ui/src/components/GPUMonitor.tsx @@ -50,22 +50,22 @@ const GpuMonitor: React.FC = () => { case 1: return 'grid-cols-1'; case 2: - return 'grid-cols-2'; + return 'grid-cols-1 sm:grid-cols-2'; case 3: - return 'grid-cols-3'; + return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'; case 4: - return 'grid-cols-4'; + return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'; case 5: case 6: - return 'grid-cols-3'; + return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'; case 7: case 8: - return 'grid-cols-4'; + return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'; case 9: case 10: - return 'grid-cols-5'; + return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-5'; default: - return 'grid-cols-3'; + return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'; } }; diff --git a/ui/src/components/JobActionBar.tsx b/ui/src/components/JobActionBar.tsx index 5623ae64..4520a04f 100644 --- a/ui/src/components/JobActionBar.tsx +++ b/ui/src/components/JobActionBar.tsx @@ -30,8 +30,9 @@ export default function JobActionBar({ if (!afterDelete) afterDelete = onRefresh; + const iconSizeClass = 'w-5 h-5 sm:w-6 sm:h-6'; return ( -
+
{canStart && ( )} {canRemoveFromQueue && ( @@ -55,9 +56,9 @@ export default function JobActionBar({ await markJobAsStopped(job.id); if (onRefresh) onRefresh(); }} - className={`ml-2 opacity-100`} + className={`ml-1 sm:ml-2 opacity-100`} > - + )} {canStop && ( @@ -75,19 +76,19 @@ export default function JobActionBar({ }, }); }} - className={`ml-2 opacity-100`} + className={`ml-1 sm:ml-2 opacity-100`} > - + )} {!hideView && ( - - + + )} {job.job_type === 'caption' && canEdit && (
openCaptionDatasetModal( job.job_ref || '', @@ -98,12 +99,12 @@ export default function JobActionBar({ ) } > - +
)} {job.job_type === 'train' && canEdit && ( - - + + )} -
+
- - + + {job.job_type === 'train' && ( diff --git a/ui/src/components/JobOverview.tsx b/ui/src/components/JobOverview.tsx index e4b4f132..633c71b0 100644 --- a/ui/src/components/JobOverview.tsx +++ b/ui/src/components/JobOverview.tsx @@ -90,9 +90,9 @@ export default function JobOverview({ job }: JobOverviewProps) { } return ( -
+
{/* Job Information Panel */} -
+

{job.info} @@ -165,7 +165,7 @@ export default function JobOverview({ job }: JobOverviewProps) {

{/* GPU Widget Panel */} -
+
{isCPUInfoLoaded && cpuInfo && }
{isGPUInfoLoaded && gpuList.length > 0 && }
{jobType === 'train' && ( diff --git a/ui/src/components/JobsTable.tsx b/ui/src/components/JobsTable.tsx index 547f4332..4c570cc1 100644 --- a/ui/src/components/JobsTable.tsx +++ b/ui/src/components/JobsTable.tsx @@ -160,16 +160,18 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
-
-

{jobsDict[gpuKey].name}

- # {queue?.gpu_ids} +
+

{jobsDict[gpuKey].name}

+ + # {queue?.gpu_ids} +
-
+
{queue?.is_running ? ( <> Queue Running @@ -178,7 +180,7 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT await stopQueue(queue.gpu_ids as string); refresh(); }} - className="ml-4 text-xs text-white bg-red-600 hover:bg-red-700 px-2 py-1 rounded" + className="ml-2 sm:ml-4 text-xs text-white bg-red-600 hover:bg-red-700 px-2 py-1 rounded" > STOP @@ -191,7 +193,7 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT await startQueue(gpuKey); refresh(); }} - className="ml-4 text-xs text-white bg-green-600 hover:bg-green-700 px-2 py-1 rounded" + className="ml-2 sm:ml-4 text-xs text-white bg-green-600 hover:bg-green-700 px-2 py-1 rounded" > START diff --git a/ui/src/components/Modal.tsx b/ui/src/components/Modal.tsx index ec661f0c..122e7bd6 100644 --- a/ui/src/components/Modal.tsx +++ b/ui/src/components/Modal.tsx @@ -60,7 +60,7 @@ export const Modal: React.FC = ({ {/* Modal backdrop */}
= ({ > {/* Modal panel */}
e.stopPropagation()} > {/* Modal header */} {(title || showCloseButton) && (
{title && ( - )} @@ -83,7 +83,7 @@ export const Modal: React.FC = ({ {showCloseButton && (
diff --git a/ui/src/components/SampleImageViewer.tsx b/ui/src/components/SampleImageViewer.tsx index ef5f2ed1..54bad489 100644 --- a/ui/src/components/SampleImageViewer.tsx +++ b/ui/src/components/SampleImageViewer.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; import { SampleConfig, SampleItem } from '@/types'; @@ -9,6 +9,7 @@ import { openConfirm } from './ConfirmModal'; import { apiClient } from '@/utils/api'; import { isVideo, isAudio } from '@/utils/basic'; import AudioPlayer from './AudioPlayer'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; interface Props { imgPath: string | null; // current image path @@ -199,6 +200,54 @@ export default function SampleImageViewer({ return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, onCancel, handleArrowUp, handleArrowDown, handleArrowLeft, handleArrowRight]); + // Touch swipe navigation + const touchStartRef = useRef<{ x: number; y: number } | null>(null); + const multiTouchRef = useRef(false); + const zoomedRef = useRef(false); + const SWIPE_THRESHOLD = 40; // px before a swipe counts + + const onTouchStart = useCallback((e: React.TouchEvent) => { + if (e.touches.length > 1) { + multiTouchRef.current = true; + touchStartRef.current = null; + return; + } + multiTouchRef.current = false; + const t = e.touches[0]; + if (!t) return; + touchStartRef.current = { x: t.clientX, y: t.clientY }; + }, []); + + const onTouchEnd = useCallback( + (e: React.TouchEvent) => { + const start = touchStartRef.current; + touchStartRef.current = null; + // If a pinch happened or we're zoomed in, don't treat as a swipe. + if (multiTouchRef.current || zoomedRef.current) { + if (e.touches.length === 0) multiTouchRef.current = false; + return; + } + if (!start) return; + const t = e.changedTouches[0]; + if (!t) return; + const dx = t.clientX - start.x; + const dy = t.clientY - start.y; + const absX = Math.abs(dx); + const absY = Math.abs(dy); + if (absX < SWIPE_THRESHOLD && absY < SWIPE_THRESHOLD) return; + if (absX > absY) { + // Horizontal swipe: left = next (ArrowRight), right = prev (ArrowLeft) + if (dx < 0) handleArrowRight(); + else handleArrowLeft(); + } else { + // Vertical swipe: up = next step (ArrowDown), down = prev step (ArrowUp) + if (dy < 0) handleArrowDown(); + else handleArrowUp(); + } + }, + [handleArrowLeft, handleArrowRight, handleArrowUp, handleArrowDown], + ); + if (!mounted) return null; return createPortal( @@ -208,15 +257,17 @@ export default function SampleImageViewer({ className="fixed inset-0 bg-gray-900/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in" />
-
+
{displayedImgPath && (isAudio(displayedImgPath) ? ( -
+
) : ( - Sample Image + { + zoomedRef.current = state.scale > 1.01; + }} + > + + Sample Image + + ))}
{/* # make full width */} diff --git a/ui/src/components/SampleImages.tsx b/ui/src/components/SampleImages.tsx index a587e751..e3a61ae3 100644 --- a/ui/src/components/SampleImages.tsx +++ b/ui/src/components/SampleImages.tsx @@ -48,16 +48,19 @@ export const SampleImagesMenu = ({ job }: SampleImagesMenuProps) => { return ( ); }; @@ -304,14 +307,14 @@ export default function SampleImages({ job }: SampleImagesProps) { refreshSampleImages={refreshSampleImages} />
diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 9b391db2..41d18331 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -1,10 +1,37 @@ +'use client'; + +import { useEffect } from 'react'; import Link from 'next/link'; -import { Home, Settings, BrainCircuit, Images, Plus } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { Home, Settings, BrainCircuit, Images, Plus, X } from 'lucide-react'; import { FaXTwitter, FaDiscord, FaYoutube } from 'react-icons/fa6'; +import { createGlobalState } from 'react-global-hooks'; import ThemeToggle from './ThemeToggle'; import ThemeLogo from './ThemeLogo'; +export const mobileSidebarState = createGlobalState(false); + const Sidebar = () => { + const [isMobileOpen, setIsMobileOpen] = mobileSidebarState.use(); + const pathname = usePathname(); + + // Close mobile menu on route change + useEffect(() => { + setIsMobileOpen(false); + }, [pathname]); + + // Lock body scroll when mobile menu open + useEffect(() => { + if (isMobileOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isMobileOpen]); + const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: Home }, { name: 'New Job', href: '/jobs/new', icon: Plus }, @@ -17,14 +44,21 @@ const Sidebar = () => { 'flex flex-col items-center justify-center p-1 hover:bg-gray-800 rounded-lg transition-colors'; const socialIconClass = 'w-5 h-5 text-gray-400 hover:text-white'; - return ( -
-
+ const sidebarContent = ( + <> +

Ostris AI-Toolkit

+
-
+ + ); + + return ( + <> + {/* Desktop sidebar - always visible on md+ */} +
{sidebarContent}
+ + {/* Mobile overlay sidebar */} +
setIsMobileOpen(false)} + aria-hidden="true" + /> +
+ {sidebarContent} +
+ ); }; diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx index f8a19932..87d95b2e 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout.tsx @@ -1,18 +1,35 @@ +'use client'; import classNames from 'classnames'; +import ThemeLogo from './ThemeLogo'; +import { mobileSidebarState } from './Sidebar'; interface Props { className?: string; children?: React.ReactNode; } +const MobileMenuButton: React.FC = () => { + const [, setIsMobileOpen] = mobileSidebarState.use(); + return ( + + ); +}; + export const TopBar: React.FC = ({ children, className }) => { return (
+ {children ? children : null}
); @@ -20,7 +37,7 @@ export const TopBar: React.FC = ({ children, className }) => { export const MainContent: React.FC = ({ children, className }) => { return ( -
+
{children ? children : null}
); diff --git a/version.py b/version.py index 184e18b0..53f53bc5 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -VERSION = "0.9.11" +VERSION = "0.9.12"