Made the UI mobile friendly, finally...
This commit is contained in:
parent
6ecaf679dc
commit
df851b3497
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
VERSION = "0.9.11"
|
||||
VERSION = "0.9.12"
|
||||
|
|
|
|||
Loading…
Reference in New Issue