Made the UI mobile friendly, finally...

This commit is contained in:
Jaret Burkett 2026-05-19 09:27:36 -06:00
parent 6ecaf679dc
commit df851b3497
22 changed files with 325 additions and 118 deletions

15
ui/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -10,7 +10,7 @@ export default function Dashboard() {
<>
<TopBar>
<div>
<h1 className="text-lg">Dashboard</h1>
<h1 className="text-base sm:text-lg">Dashboard</h1>
</div>
<div className="flex-1"></div>
</TopBar>

View File

@ -100,25 +100,29 @@ export default function DatasetPage({ params }: { params: { datasetName: string
<>
{/* Fixed top bar */}
<TopBar>
<div>
<Button className="text-gray-500 dark:text-gray-300 px-3 mt-1" onClick={() => history.back()}>
<div className="flex-shrink-0">
<Button className="text-gray-500 dark:text-gray-300 px-2 sm:px-3 mt-1" onClick={() => history.back()}>
<FaChevronLeft />
</Button>
</div>
<div>
<h1 className="text-lg">Dataset: {datasetName}</h1>
<div className="min-w-0 flex-shrink">
<h1 className="text-base sm:text-lg truncate">
<span className="hidden sm:inline">Dataset: </span>
{datasetName}
</h1>
</div>
<div className="flex-1"></div>
<div>
<div className="flex-shrink-0 flex items-center gap-1 sm:gap-2">
<AutoCaptionButton
datasetPath={`${pathJoin(settings.DATASETS_FOLDER, datasetName)}`}
setIsAutoCaptioning={setIsAutoCaptioning}
/>
<Button
className="text-white bg-slate-600 px-3 py-1 rounded-md"
className="text-white bg-slate-600 px-2 sm:px-3 py-1 rounded-md text-sm sm:text-base whitespace-nowrap"
onClick={() => openImagesModal(datasetName, () => refreshImageList(datasetName))}
>
Add Images
<span className="sm:hidden">+ Add</span>
<span className="hidden sm:inline">Add Images</span>
</Button>
</div>
</TopBar>

View File

@ -114,15 +114,16 @@ export default function Datasets() {
<>
<TopBar>
<div>
<h1 className="text-lg">Datasets</h1>
<h1 className="text-base sm:text-lg">Datasets</h1>
</div>
<div className="flex-1"></div>
<div>
<Button
className="text-white bg-slate-600 px-3 py-1 rounded-md hover:bg-slate-500 transition-colors"
className="text-white bg-slate-600 px-2 sm:px-3 py-1 rounded-md hover:bg-slate-500 transition-colors text-sm sm:text-base whitespace-nowrap"
onClick={() => openNewDatasetModal()}
>
New Dataset
<span className="sm:hidden">+ New</span>
<span className="hidden sm:inline">New Dataset</span>
</Button>
</div>
</TopBar>

View File

@ -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 {

View File

@ -79,13 +79,13 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
<>
{/* Fixed top bar */}
<TopBar>
<div>
<Button className="text-gray-500 dark:text-gray-300 px-3 mt-1" onClick={() => redirect('/jobs')}>
<div className="flex-shrink-0">
<Button className="text-gray-500 dark:text-gray-300 pl-0 pr-1 sm:px-3 mt-1" onClick={() => redirect('/jobs')}>
<FaChevronLeft />
</Button>
</div>
<div>
<h1 className="text-lg">{title}</h1>
<div className="min-w-0 flex-shrink">
<h1 className="text-base sm:text-lg truncate">{title}</h1>
</div>
<div className="flex-1"></div>
{job && (
@ -112,7 +112,7 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
</>
)}
</MainContent>
<div className="bg-gray-800 absolute top-12 left-0 w-full h-8 flex items-center px-2 text-sm">
<div className="bg-gray-800 absolute top-12 left-0 w-full h-8 flex items-center px-0 sm:px-2 text-sm sm:overflow-x-auto whitespace-nowrap">
{pages.map(page => {
if (page.jobTypes && !page.jobTypes.includes(jobType)) {
return null;
@ -121,16 +121,16 @@ export default function JobPage({ params }: { params: { jobID: string } }) {
<Button
key={page.value}
onClick={() => setPageKey(page.value)}
className={`px-4 py-1 h-8 flex items-center gap-1.5 ${page.value === pageKey ? 'bg-gray-300 dark:bg-gray-700 text-white' : ''}`}
className={`flex-1 sm:flex-initial justify-center px-2 sm:px-4 py-1 h-8 flex items-center gap-1.5 sm:flex-shrink-0 ${page.value === pageKey ? 'bg-gray-300 dark:bg-gray-700 text-white' : ''}`}
>
<page.icon className="text-sm" />
{page.name}
<span className="hidden sm:inline">{page.name}</span>
</Button>
);
})}
{page?.menuItem && (
<>
<div className="flex-grow"></div>
<div className="hidden sm:block flex-grow"></div>
<page.menuItem job={job} />
</>
)}

View File

@ -190,36 +190,38 @@ export default function TrainingForm() {
return (
<>
<TopBar>
<div>
<Button className="text-gray-500 dark:text-gray-300 px-3 mt-1" onClick={() => history.back()}>
<div className="flex-shrink-0">
<Button className="text-gray-500 dark:text-gray-300 px-2 sm:px-3 mt-1" onClick={() => history.back()}>
<FaChevronLeft />
</Button>
</div>
<div>
<h1 className="text-lg">{runId ? 'Edit Training Job' : 'New Training Job'}</h1>
<div className="flex-shrink-0">
<h1 className="text-base sm:text-lg truncate max-w-[120px] sm:max-w-none">
{runId ? 'Edit Training Job' : 'New Training Job'}
</h1>
</div>
<div className="flex-1"></div>
{showAdvancedView && (
<>
<div>
<div className="hidden sm:block">
<SelectInput
value={`${gpuIDs}`}
onChange={value => setGpuIDs(value)}
options={gpuList.map((gpu: any) => ({ value: `${gpu.index}`, label: `GPU #${gpu.index}` }))}
/>
</div>
<div className="mx-4 bg-gray-200 dark:bg-gray-800 w-1 h-6"></div>
<div>
<div className="hidden sm:block mx-4 bg-gray-200 dark:bg-gray-800 w-1 h-6"></div>
<div className="hidden md:block">
<Button className="text-gray-200 bg-gray-800 px-3 py-1 rounded-md" onClick={handleImportConfig}>
Import Config
</Button>
</div>
<div className="mx-4 bg-gray-200 dark:bg-gray-800 w-1 h-6"></div>
<div className="hidden md:block mx-4 bg-gray-200 dark:bg-gray-800 w-1 h-6"></div>
</>
)}
{!showAdvancedView && (
<>
<div>
<div className="hidden sm:block">
<SelectInput
value={`${jobConfig?.config.process[0].type}`}
onChange={value => {
@ -246,25 +248,33 @@ export default function TrainingForm() {
options={jobTypeOptions}
/>
</div>
<div className="mx-4 bg-gray-200 dark:bg-gray-800 w-1 h-6"></div>
<div className="hidden sm:block mx-4 bg-gray-200 dark:bg-gray-800 w-1 h-6"></div>
</>
)}
<div className="pr-2">
<div className="pr-1 sm:pr-2 flex-shrink-0">
<Button
className="text-gray-200 bg-gray-800 px-3 py-1 rounded-md"
className="text-gray-200 bg-gray-800 px-2 sm:px-3 py-1 rounded-md text-xs sm:text-base"
onClick={() => setShowAdvancedView(!showAdvancedView)}
>
{showAdvancedView ? 'Show Simple' : 'Show Advanced'}
<span className="sm:hidden">{showAdvancedView ? 'Simple' : 'Advanced'}</span>
<span className="hidden sm:inline">{showAdvancedView ? 'Show Simple' : 'Show Advanced'}</span>
</Button>
</div>
<div>
<div className="flex-shrink-0">
<Button
className="text-white bg-green-600 hover:bg-green-700 px-3 py-1 rounded-md"
className="text-white bg-green-600 hover:bg-green-700 px-2 sm:px-3 py-1 rounded-md text-xs sm:text-base"
onClick={() => saveJob()}
disabled={status === 'saving'}
>
{status === 'saving' ? 'Saving...' : runId ? 'Update Job' : 'Create Job'}
{status === 'saving' ? (
'Saving...'
) : (
<>
<span className="sm:hidden">{runId ? 'Update' : 'Create'}</span>
<span className="hidden sm:inline">{runId ? 'Update Job' : 'Create Job'}</span>
</>
)}
</Button>
</div>
</TopBar>

View File

@ -9,12 +9,16 @@ export default function Dashboard() {
<>
<TopBar>
<div>
<h1 className="text-lg">Queue</h1>
<h1 className="text-base sm:text-lg">Queue</h1>
</div>
<div className="flex-1"></div>
<div>
<Link href="/jobs/new" className="text-white bg-slate-600 px-3 py-1 rounded-md">
New Training Job
<Link
href="/jobs/new"
className="text-white bg-slate-600 px-2 sm:px-3 py-1 rounded-md text-sm sm:text-base whitespace-nowrap"
>
<span className="sm:hidden">+ New Job</span>
<span className="hidden sm:inline">New Training Job</span>
</Link>
</div>
</TopBar>

View File

@ -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;

View File

@ -36,7 +36,7 @@ export default function Settings() {
<>
<TopBar>
<div>
<h1 className="text-lg">Settings</h1>
<h1 className="text-base sm:text-lg">Settings</h1>
</div>
<div className="flex-1"></div>
</TopBar>

View File

@ -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 (
<Link href={`/jobs/${job.id}`} className="text-white bg-gray-400 px-3 py-1 rounded-md mr-2 inline-flex items-center gap-1.5">
<Link
href={`/jobs/${job.id}`}
className="text-white bg-gray-400 px-2 sm:px-3 py-1 rounded-md mr-1 sm:mr-2 inline-flex items-center gap-1 sm:gap-1.5 text-sm sm:text-base whitespace-nowrap"
>
<Loader2 className="w-4 h-4 animate-spin" />
Auto Captioning...
<span className="hidden sm:inline">Auto Captioning...</span>
<span className="sm:hidden">Captioning</span>
</Link>
);
}
return (
<Button
className="text-white bg-blue-600 px-3 py-1 rounded-md mr-2"
onClick={() => openCaptionDatasetModal(datasetPath, () => {
refreshJob();
})}
className="text-white bg-blue-600 px-2 sm:px-3 py-1 rounded-md mr-1 sm:mr-2 text-sm sm:text-base whitespace-nowrap"
onClick={() =>
openCaptionDatasetModal(datasetPath, () => {
refreshJob();
})
}
>
Auto Caption
<span className="hidden sm:inline">Auto Caption</span>
<span className="sm:hidden">Caption</span>
</Button>
);
}

View File

@ -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';
}
};

View File

@ -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 (
<div className={`${className}`}>
<div className={`flex items-center flex-shrink-0 ${className ?? ''}`}>
{canStart && (
<Button
onClick={async () => {
@ -43,9 +44,9 @@ export default function JobActionBar({
}
if (onRefresh) onRefresh();
}}
className={`ml-2 opacity-100`}
className={`ml-1 sm:ml-2 opacity-100`}
>
<Play />
<Play className={iconSizeClass} />
</Button>
)}
{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`}
>
<X />
<X className={iconSizeClass} />
</Button>
)}
{canStop && (
@ -75,19 +76,19 @@ export default function JobActionBar({
},
});
}}
className={`ml-2 opacity-100`}
className={`ml-1 sm:ml-2 opacity-100`}
>
<Pause />
<Pause className={iconSizeClass} />
</Button>
)}
{!hideView && (
<Link href={`/jobs/${job.id}`} className="ml-2 text-gray-200 hover:text-gray-100 inline-block">
<Eye />
<Link href={`/jobs/${job.id}`} className="ml-1 sm:ml-2 text-gray-200 hover:text-gray-100 inline-block">
<Eye className={iconSizeClass} />
</Link>
)}
{job.job_type === 'caption' && canEdit && (
<div
className="ml-2 hover:text-gray-100 inline-block cursor-pointer"
className="ml-1 sm:ml-2 hover:text-gray-100 inline-block cursor-pointer"
onClick={() =>
openCaptionDatasetModal(
job.job_ref || '',
@ -98,12 +99,12 @@ export default function JobActionBar({
)
}
>
<Pen />
<Pen className={iconSizeClass} />
</div>
)}
{job.job_type === 'train' && canEdit && (
<Link href={`/jobs/new?id=${job.id}`} className="ml-2 hover:text-gray-100 inline-block">
<Pen />
<Link href={`/jobs/new?id=${job.id}`} className="ml-1 sm:ml-2 hover:text-gray-100 inline-block">
<Pen className={iconSizeClass} />
</Link>
)}
<Button
@ -130,14 +131,14 @@ export default function JobActionBar({
},
});
}}
className={`ml-2 opacity-100`}
className={`ml-1 sm:ml-2 opacity-100`}
>
<Trash2 />
<Trash2 className={iconSizeClass} />
</Button>
<div className="border-r border-1 border-gray-700 ml-2 inline"></div>
<div className="border-r border-1 border-gray-700 ml-1 sm:ml-2 inline"></div>
<Menu>
<MenuButton className={'ml-2'}>
<Cog />
<MenuButton className={'ml-1 sm:ml-2'}>
<Cog className={iconSizeClass} />
</MenuButton>
<MenuItems anchor="bottom" className="bg-gray-900 border border-gray-700 rounded shadow-lg w-48 px-2 py-2 mt-4">
{job.job_type === 'train' && (

View File

@ -90,9 +90,9 @@ export default function JobOverview({ job }: JobOverviewProps) {
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:gap-6 md:grid-cols-3">
{/* Job Information Panel */}
<div className="col-span-2 bg-gray-900 rounded-xl shadow-lg overflow-hidden border border-gray-800 flex flex-col">
<div className="md:col-span-2 bg-gray-900 rounded-xl shadow-lg overflow-hidden border border-gray-800 flex flex-col">
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-gray-100">
<Info className="w-5 h-5 mr-2 -mt-1 text-amber-600 dark:text-amber-400 inline-block" /> {job.info}
@ -165,7 +165,7 @@ export default function JobOverview({ job }: JobOverviewProps) {
</div>
{/* GPU Widget Panel */}
<div className="col-span-1">
<div className="md:col-span-1">
<div>{isCPUInfoLoaded && cpuInfo && <CPUWidget cpu={cpuInfo} />}</div>
<div className="mt-4">{isGPUInfoLoaded && gpuList.length > 0 && <GPUWidget gpu={gpuList[0]} />}</div>
{jobType === 'train' && (

View File

@ -160,16 +160,18 @@ export default function JobsTable({ onlyActive = false, job_type = null }: JobsT
<div key={gpuKey} className="mb-6">
<div
className={classNames(
'text-md flex px-4 py-1 rounded-t-lg',
'text-md flex flex-wrap gap-y-1 px-2 sm:px-4 py-1 rounded-t-lg',
{ 'bg-green-600 dark:bg-green-900': queue?.is_running },
{ 'bg-red-600 dark:bg-red-900': !queue?.is_running },
)}
>
<div className="flex items-center space-x-2 flex-1 py-2">
<h2 className="font-semibold text-white">{jobsDict[gpuKey].name}</h2>
<span className="px-2 py-0.5 bg-gray-700 rounded-full text-xs text-gray-300"># {queue?.gpu_ids}</span>
<div className="flex items-center space-x-2 flex-1 min-w-0 py-2">
<h2 className="font-semibold text-white truncate">{jobsDict[gpuKey].name}</h2>
<span className="px-2 py-0.5 bg-gray-700 rounded-full text-xs text-gray-300 flex-shrink-0">
# {queue?.gpu_ids}
</span>
</div>
<div className="text-sm text-gray-300 italic flex items-center">
<div className="text-sm text-gray-300 italic flex items-center flex-shrink-0">
{queue?.is_running ? (
<>
<span className="text-green-100 dark:text-green-400 mr-2">Queue Running</span>
@ -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
</button>
@ -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
</button>

View File

@ -60,7 +60,7 @@ export const Modal: React.FC<ModalProps> = ({
<Fragment>
{/* Modal backdrop */}
<div
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-900 bg-opacity-75 backdrop-blur-sm transition-opacity"
className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-gray-900 bg-opacity-75 backdrop-blur-sm transition-opacity p-2 sm:p-4"
onClick={handleOverlayClick}
aria-modal="true"
role="dialog"
@ -68,14 +68,14 @@ export const Modal: React.FC<ModalProps> = ({
>
{/* Modal panel */}
<div
className={`relative mx-auto w-full ${sizeClasses[size]} rounded-xl bg-gray-900 shadow-xl shadow-black/10 dark:shadow-2xl dark:shadow-black/80 transition-all`}
className={`relative mx-auto w-full ${sizeClasses[size]} max-h-full overflow-y-auto rounded-xl bg-gray-900 shadow-xl shadow-black/10 dark:shadow-2xl dark:shadow-black/80 transition-all`}
onClick={e => e.stopPropagation()}
>
{/* Modal header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between bg-gray-800 px-4 py-3 rounded-t-xl">
{title && (
<h3 id="modal-title" className="font-semibold text-gray-100">
<h3 id="modal-title" className="font-semibold text-gray-100 truncate pr-2">
{title}
</h3>
)}
@ -83,7 +83,7 @@ export const Modal: React.FC<ModalProps> = ({
{showCloseButton && (
<button
type="button"
className="ml-auto inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="ml-auto inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 flex-shrink-0"
onClick={onClose}
aria-label="Close modal"
>
@ -102,7 +102,7 @@ export const Modal: React.FC<ModalProps> = ({
)}
{/* Modal content */}
<div className="px-6 py-4">{children}</div>
<div className="px-4 sm:px-6 py-4">{children}</div>
</div>
</div>
</Fragment>

View File

@ -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"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<div className="flex min-h-full items-center justify-center p-0 sm:p-4 text-center">
<DialogPanel
transition
className="relative transform rounded-lg bg-gray-800 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in max-w-[95%] max-h-[95vh] data-closed:sm:translate-y-0 data-closed:sm:scale-95 flex flex-col overflow-hidden"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
className="relative transform rounded-none sm:rounded-lg bg-gray-800 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in w-full sm:w-auto sm:max-w-[95%] sm:max-h-[95vh] data-closed:sm:translate-y-0 data-closed:sm:scale-95 flex flex-col overflow-hidden touch-pan-y"
>
<div className="overflow-hidden flex items-center justify-center">
{displayedImgPath &&
(isAudio(displayedImgPath) ? (
<div className="w-[500px] h-[500px] max-w-[95vw] max-h-[82vh]">
<div className="w-[500px] h-[500px] max-w-full sm:max-w-[95vw] max-h-[82vh]">
<AudioPlayer
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
title={displayedImgPath.replace(/^.*[\\/]/, '')}
@ -226,7 +277,7 @@ export default function SampleImageViewer({
) : isVideo(displayedImgPath) ? (
<video
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
className="w-auto h-auto max-w-[95vw] max-h-[82vh] object-contain"
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[82vh] object-contain"
preload="none"
playsInline
loop
@ -234,11 +285,27 @@ export default function SampleImageViewer({
controls={true}
/>
) : (
<img
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
alt="Sample Image"
className="w-auto h-auto max-w-[95vw] max-h-[82vh] object-contain"
/>
<TransformWrapper
key={displayedImgPath}
initialScale={1}
minScale={1}
maxScale={6}
doubleClick={{ mode: 'toggle', step: 2 }}
wheel={{ step: 0.2 }}
panning={{ disabled: false }}
onTransform={(_ref, state) => {
zoomedRef.current = state.scale > 1.01;
}}
>
<TransformComponent wrapperClass="!w-full !h-auto" contentClass="!w-full !h-auto">
<img
src={`/api/img/${encodeURIComponent(displayedImgPath)}`}
alt="Sample Image"
draggable={false}
className="w-auto h-auto max-w-full sm:max-w-[95vw] max-h-[82vh] object-contain select-none"
/>
</TransformComponent>
</TransformWrapper>
))}
</div>
{/* # make full width */}

View File

@ -48,16 +48,19 @@ export const SampleImagesMenu = ({ job }: SampleImagesMenuProps) => {
return (
<Button
onClick={downloadZip}
className={classNames(`px-4 py-1 h-8 hover:bg-gray-200 dark:hover:bg-gray-700`, {
'opacity-50 cursor-not-allowed': isZipping,
})}
className={classNames(
`flex-1 sm:flex-initial justify-center px-2 sm:px-4 py-1 h-8 hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center`,
{
'opacity-50 cursor-not-allowed': isZipping,
},
)}
>
{isZipping ? (
<LuLoader className="animate-spin inline-block mr-2" />
<LuLoader className="animate-spin inline-block sm:mr-2" />
) : (
<FaDownload className="inline-block mr-2" />
<FaDownload className="inline-block sm:mr-2" />
)}
{isZipping ? 'Preparing' : 'Download'}
<span className="hidden sm:inline">{isZipping ? 'Preparing' : 'Download'}</span>
</Button>
);
};
@ -304,14 +307,14 @@ export default function SampleImages({ job }: SampleImagesProps) {
refreshSampleImages={refreshSampleImages}
/>
<div
className="fixed top-20 mt-4 right-6 w-10 h-10 rounded-full bg-gray-900 shadow-lg flex items-center justify-center text-white opacity-80 hover:opacity-100 cursor-pointer"
className="hidden md:flex fixed top-20 mt-4 right-6 w-10 h-10 rounded-full bg-gray-900 shadow-lg items-center justify-center text-white opacity-80 hover:opacity-100 cursor-pointer"
onClick={scrollToTop}
title="Scroll to Top"
>
<FaCaretUp className="text-gray-500 dark:text-gray-400" />
</div>
<div
className="fixed bottom-5 right-6 w-10 h-10 rounded-full bg-gray-900 shadow-lg flex items-center justify-center text-white opacity-80 hover:opacity-100 cursor-pointer"
className="hidden md:flex fixed bottom-5 right-6 w-10 h-10 rounded-full bg-gray-900 shadow-lg items-center justify-center text-white opacity-80 hover:opacity-100 cursor-pointer"
onClick={scrollToBottom}
title="Scroll to Bottom"
>

View File

@ -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<boolean>(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 (
<div className="flex flex-col w-59 bg-gray-900 text-gray-100">
<div className="px-4 py-3">
const sidebarContent = (
<>
<div className="px-4 py-3 flex items-center justify-between">
<h1 className="text-l">
<ThemeLogo />
<span className="font-bold uppercase">Ostris</span>
<span className="ml-2 uppercase text-gray-300">AI-Toolkit</span>
</h1>
<button
onClick={() => setIsMobileOpen(false)}
className="md:hidden text-gray-400 hover:text-white p-1"
aria-label="Close menu"
>
<X className="w-5 h-5" />
</button>
</div>
<nav className="flex-1">
<ul className="px-2 py-4 space-y-2">
@ -65,20 +99,40 @@ const Sidebar = () => {
<div className="grid grid-cols-4 gap-4">
<a href="https://discord.gg/VXmU2f5WEU" target="_blank" rel="noreferrer" className={socialsBoxClass}>
<FaDiscord className={socialIconClass} />
{/* <span className="text-xs text-gray-500 mt-1">Discord</span> */}
</a>
<a href="https://www.youtube.com/@ostrisai" target="_blank" rel="noreferrer" className={socialsBoxClass}>
<FaYoutube className={socialIconClass} />
{/* <span className="text-xs text-gray-500 mt-1">YouTube</span> */}
</a>
<a href="https://x.com/ostrisai" target="_blank" rel="noreferrer" className={socialsBoxClass}>
<FaXTwitter className={socialIconClass} />
{/* <span className="text-xs text-gray-500 mt-1">X</span> */}
</a>
<ThemeToggle />
</div>
</div>
</div>
</>
);
return (
<>
{/* Desktop sidebar - always visible on md+ */}
<div className="hidden md:flex flex-col w-59 bg-gray-900 text-gray-100">{sidebarContent}</div>
{/* Mobile overlay sidebar */}
<div
className={`md:hidden fixed inset-0 bg-black/60 z-40 transition-opacity duration-300 ease-in-out ${
isMobileOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
onClick={() => setIsMobileOpen(false)}
aria-hidden="true"
/>
<div
className={`md:hidden fixed top-0 left-0 bottom-0 w-64 max-w-[85vw] bg-gray-900 text-gray-100 z-50 flex flex-col shadow-xl transform transition-transform duration-300 ease-in-out ${
isMobileOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{sidebarContent}
</div>
</>
);
};

View File

@ -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 (
<button
onClick={() => setIsMobileOpen(true)}
className="md:hidden flex items-center ml-2 mr-1 px-1 py-1 rounded-md hover:bg-gray-800"
aria-label="Open menu"
>
<ThemeLogo />
</button>
);
};
export const TopBar: React.FC<Props> = ({ children, className }) => {
return (
<div
className={classNames(
'absolute top-0 left-0 w-full h-12 bg-gray-900 shadow-sm z-10 flex items-center px-2',
'absolute top-0 left-0 w-full h-12 bg-gray-900 shadow-sm z-10 flex items-center px-2 overflow-x-auto whitespace-nowrap',
className,
)}
>
<MobileMenuButton />
{children ? children : null}
</div>
);
@ -20,7 +37,7 @@ export const TopBar: React.FC<Props> = ({ children, className }) => {
export const MainContent: React.FC<Props> = ({ children, className }) => {
return (
<div className={classNames('pt-14 px-4 absolute top-0 left-0 w-full h-full overflow-auto', className)}>
<div className={classNames('pt-14 px-2 sm:px-4 absolute top-0 left-0 w-full h-full overflow-auto', className)}>
{children ? children : null}
</div>
);

View File

@ -1 +1 @@
VERSION = "0.9.11"
VERSION = "0.9.12"