new web init
This commit is contained in:
parent
a5c241ac02
commit
04c5dfece8
18
web_app/.eslintrc.cjs
Normal file
18
web_app/.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
24
web_app/.gitignore
vendored
Normal file
24
web_app/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
30
web_app/README.md
Normal file
30
web_app/README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
16
web_app/components.json
Normal file
16
web_app/components.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "gray",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
13
web_app/index.html
Normal file
13
web_app/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
5775
web_app/package-lock.json
generated
Normal file
5775
web_app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
web_app/package.json
Normal file
65
web_app/package.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "web_app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toggle": "^1.0.3",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"flexsearch": "^0.7.21",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.292.0",
|
||||
"mitt": "^3.0.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-photo-album": "^2.3.0",
|
||||
"react-use": "^17.4.0",
|
||||
"react-zoom-pan-pinch": "^3.3.0",
|
||||
"recoil": "^0.7.7",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/lodash": "^4.14.201",
|
||||
"@types/node": "^20.9.2",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
6
web_app/postcss.config.js
Normal file
6
web_app/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
173
web_app/src/App.tsx
Normal file
173
web_app/src/App.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { nanoid } from "nanoid"
|
||||
import { useRecoilState, useSetRecoilState } from "recoil"
|
||||
import { fileState, serverConfigState } from "@/lib/store"
|
||||
import useInputImage from "@/hooks/useInputImage"
|
||||
import { keepGUIAlive } from "@/lib/utils"
|
||||
import { getServerConfig, isDesktop } from "@/lib/api"
|
||||
import Header from "@/components/Header"
|
||||
import Workspace from "@/components/Workspace"
|
||||
import FileSelect from "@/components/FileSelect"
|
||||
import { Toaster } from "./components/ui/toaster"
|
||||
|
||||
const SUPPORTED_FILE_TYPE = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/tiff",
|
||||
]
|
||||
function Home() {
|
||||
const [file, setFile] = useRecoilState(fileState)
|
||||
const userInputImage = useInputImage()
|
||||
const setServerConfigState = useSetRecoilState(serverConfigState)
|
||||
|
||||
// Set Input Image
|
||||
useEffect(() => {
|
||||
setFile(userInputImage)
|
||||
}, [userInputImage, setFile])
|
||||
|
||||
// Keeping GUI Window Open
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const isRunDesktop = await isDesktop().then((res) => res.text())
|
||||
if (isRunDesktop === "True") {
|
||||
keepGUIAlive()
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServerConfig = async () => {
|
||||
const serverConfig = await getServerConfig().then((res) => res.json())
|
||||
console.log(serverConfig)
|
||||
setServerConfigState(serverConfig)
|
||||
}
|
||||
fetchServerConfig()
|
||||
}, [])
|
||||
|
||||
const workspaceId = useMemo(() => {
|
||||
return nanoid()
|
||||
}, [file])
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const handleDrag = useCallback((event: any) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragIn = useCallback((event: any) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragCounter.current += 1
|
||||
if (event.dataTransfer.items && event.dataTransfer.items.length > 0) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOut = useCallback((event: any) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragCounter.current -= 1
|
||||
if (dragCounter.current > 0) return
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((event: any) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDragging(false)
|
||||
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
if (event.dataTransfer.files.length > 1) {
|
||||
// setToastState({
|
||||
// open: true,
|
||||
// desc: "Please drag and drop only one file",
|
||||
// state: "error",
|
||||
// duration: 3000,
|
||||
// })
|
||||
} else {
|
||||
const dragFile = event.dataTransfer.files[0]
|
||||
const fileType = dragFile.type
|
||||
if (SUPPORTED_FILE_TYPE.includes(fileType)) {
|
||||
setFile(dragFile)
|
||||
} else {
|
||||
// setToastState({
|
||||
// open: true,
|
||||
// desc: "Please drag and drop an image file",
|
||||
// state: "error",
|
||||
// duration: 3000,
|
||||
// })
|
||||
}
|
||||
}
|
||||
event.dataTransfer.clearData()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPaste = useCallback((event: any) => {
|
||||
// TODO: when sd side panel open, ctrl+v not work
|
||||
// https://htmldom.dev/paste-an-image-from-the-clipboard/
|
||||
if (!event.clipboardData) {
|
||||
return
|
||||
}
|
||||
const clipboardItems = event.clipboardData.items
|
||||
const items: DataTransferItem[] = [].slice
|
||||
.call(clipboardItems)
|
||||
.filter((item: DataTransferItem) => {
|
||||
// Filter the image items only
|
||||
return item.type.indexOf("image") !== -1
|
||||
})
|
||||
|
||||
if (items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// TODO: add confirm dialog
|
||||
|
||||
const item = items[0]
|
||||
// Get the blob of image
|
||||
const blob = item.getAsFile()
|
||||
if (blob) {
|
||||
setFile(blob)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("dragenter", handleDragIn)
|
||||
window.addEventListener("dragleave", handleDragOut)
|
||||
window.addEventListener("dragover", handleDrag)
|
||||
window.addEventListener("drop", handleDrop)
|
||||
window.addEventListener("paste", onPaste)
|
||||
return function cleanUp() {
|
||||
window.removeEventListener("dragenter", handleDragIn)
|
||||
window.removeEventListener("dragleave", handleDragOut)
|
||||
window.removeEventListener("dragover", handleDrag)
|
||||
window.removeEventListener("drop", handleDrop)
|
||||
window.removeEventListener("paste", onPaste)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between w-full">
|
||||
<Toaster />
|
||||
<Header />
|
||||
<Workspace key={workspaceId} />
|
||||
{!file ? (
|
||||
<FileSelect
|
||||
onSelection={async (f) => {
|
||||
setFile(f)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
1704
web_app/src/components/Editor.tsx
Normal file
1704
web_app/src/components/Editor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
web_app/src/components/FileManager.tsx
Normal file
321
web_app/src/components/FileManager.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import {
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
FormEvent,
|
||||
} from "react"
|
||||
import _ from "lodash"
|
||||
import { useRecoilState } from "recoil"
|
||||
import PhotoAlbum from "react-photo-album"
|
||||
import {
|
||||
BarsArrowDownIcon,
|
||||
BarsArrowUpIcon,
|
||||
FolderIcon,
|
||||
} from "@heroicons/react/24/outline"
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ViewHorizontalIcon,
|
||||
ViewGridIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import { useDebounce, useToggle } from "react-use"
|
||||
import FlexSearch from "flexsearch/dist/flexsearch.bundle.js"
|
||||
import {
|
||||
fileManagerLayout,
|
||||
fileManagerSearchText,
|
||||
fileManagerSortBy,
|
||||
fileManagerSortOrder,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
} from "@/lib/store"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { API_ENDPOINT, getMedias } from "@/lib/api"
|
||||
import { IconButton } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
interface Photo {
|
||||
src: string
|
||||
height: number
|
||||
width: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Filename {
|
||||
name: string
|
||||
height: number
|
||||
width: number
|
||||
ctime: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
const SORT_BY_NAME = "Name"
|
||||
const SORT_BY_CREATED_TIME = "Created time"
|
||||
const SORT_BY_MODIFIED_TIME = "Modified time"
|
||||
|
||||
const IMAGE_TAB = "image"
|
||||
const OUTPUT_TAB = "output"
|
||||
|
||||
const SortByMap = {
|
||||
[SortBy.NAME]: SORT_BY_NAME,
|
||||
[SortBy.CTIME]: SORT_BY_CREATED_TIME,
|
||||
[SortBy.MTIME]: SORT_BY_MODIFIED_TIME,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPhotoClick(tab: string, filename: string): void
|
||||
photoWidth: number
|
||||
}
|
||||
|
||||
export default function FileManager(props: Props) {
|
||||
const { onPhotoClick, photoWidth } = props
|
||||
const [open, toggleOpen] = useToggle(false)
|
||||
|
||||
useHotkeys("f", () => {
|
||||
toggleOpen()
|
||||
})
|
||||
|
||||
const { toast } = useToast()
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const [closeScrollTop, setCloseScrollTop] = useState(0)
|
||||
|
||||
const [sortBy, setSortBy] = useRecoilState<SortBy>(fileManagerSortBy)
|
||||
const [sortOrder, setSortOrder] = useRecoilState(fileManagerSortOrder)
|
||||
const [layout, setLayout] = useRecoilState(fileManagerLayout)
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useRecoilState(
|
||||
fileManagerSearchText
|
||||
)
|
||||
const ref = useRef(null)
|
||||
const [searchText, setSearchText] = useState(debouncedSearchText)
|
||||
const [tab, setTab] = useState(IMAGE_TAB)
|
||||
const [photos, setPhotos] = useState<Photo[]>([])
|
||||
|
||||
const [, cancel] = useDebounce(
|
||||
() => {
|
||||
setDebouncedSearchText(searchText)
|
||||
},
|
||||
300,
|
||||
[searchText]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCloseScrollTop(scrollTop)
|
||||
}
|
||||
}, [open, scrollTop])
|
||||
|
||||
const onRefChange = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
if (node !== null) {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
// TODO: without timeout, scrollTo not work, why?
|
||||
node.scrollTo({ top: closeScrollTop, left: 0 })
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, closeScrollTop]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const filenames = await getMedias(tab)
|
||||
let filteredFilenames = filenames
|
||||
if (debouncedSearchText) {
|
||||
const index = new FlexSearch.Index({
|
||||
tokenize: "forward",
|
||||
minlength: 1,
|
||||
})
|
||||
filenames.forEach((filename: Filename, id: number) =>
|
||||
index.add(id, filename.name)
|
||||
)
|
||||
const results: FlexSearch.IndexSearchResult =
|
||||
index.search(debouncedSearchText)
|
||||
filteredFilenames = results.map(
|
||||
(id: FlexSearch.Id) => filenames[id as number]
|
||||
)
|
||||
}
|
||||
|
||||
filteredFilenames = _.orderBy(filteredFilenames, sortBy, sortOrder)
|
||||
|
||||
const newPhotos = filteredFilenames.map((filename: Filename) => {
|
||||
const width = photoWidth
|
||||
const height = filename.height * (width / filename.width)
|
||||
const src = `${API_ENDPOINT}/media_thumbnail/${tab}/${filename.name}?width=${width}&height=${height}`
|
||||
return { src, height, width, name: filename.name }
|
||||
})
|
||||
setPhotos(newPhotos)
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Uh oh! Something went wrong.",
|
||||
description: e.message ? e.message : e.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [tab, debouncedSearchText, sortBy, sortOrder, photoWidth, open])
|
||||
|
||||
const onScroll = (event: SyntheticEvent) => {
|
||||
setScrollTop(event.currentTarget.scrollTop)
|
||||
}
|
||||
|
||||
const onClick = ({ index }: { index: number }) => {
|
||||
toggleOpen()
|
||||
onPhotoClick(tab, photos[index].name)
|
||||
}
|
||||
|
||||
const renderTitle = () => {
|
||||
return (
|
||||
<div className="flex justify-start items-center gap-[12px]">
|
||||
<div>{`Images (${photos.length})`}</div>
|
||||
<div className="flex">
|
||||
<IconButton
|
||||
tooltip="Rows layout"
|
||||
onClick={() => {
|
||||
setLayout("rows")
|
||||
}}
|
||||
>
|
||||
<ViewHorizontalIcon
|
||||
className={layout !== "rows" ? "opacity-50" : ""}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip="Grid layout"
|
||||
onClick={() => {
|
||||
setLayout("masonry")
|
||||
}}
|
||||
className={layout !== "masonry" ? "opacity-50" : ""}
|
||||
>
|
||||
<ViewGridIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={toggleOpen}>
|
||||
<DialogTrigger>
|
||||
<IconButton tooltip="File Manager">
|
||||
<FolderIcon />
|
||||
</IconButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="h-4/5 max-w-6xl">
|
||||
<DialogTitle>{renderTitle()}</DialogTitle>
|
||||
<div className="flex justify-between gap-8 items-center">
|
||||
<div className="flex relative justify-start items-center">
|
||||
<MagnifyingGlassIcon className="absolute left-[8px]" />
|
||||
<Input
|
||||
ref={ref}
|
||||
value={searchText}
|
||||
className="w-[250px] pl-[30px]"
|
||||
tabIndex={-1}
|
||||
onInput={(evt: FormEvent<HTMLInputElement>) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as HTMLInputElement
|
||||
setSearchText(target.value)
|
||||
}}
|
||||
placeholder="Search by file name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={tab} onValueChange={(val) => setTab(val)}>
|
||||
<TabsList aria-label="Manage your account">
|
||||
<TabsTrigger value={IMAGE_TAB}>Image Directory</TabsTrigger>
|
||||
<TabsTrigger value={OUTPUT_TAB}>Output Directory</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Select
|
||||
value={SortByMap[sortBy]}
|
||||
onValueChange={(val) => {
|
||||
switch (val) {
|
||||
case SORT_BY_NAME:
|
||||
setSortBy(SortBy.NAME)
|
||||
break
|
||||
case SORT_BY_CREATED_TIME:
|
||||
setSortBy(SortBy.CTIME)
|
||||
break
|
||||
case SORT_BY_MODIFIED_TIME:
|
||||
setSortBy(SortBy.MTIME)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(SortByMap).map((val) => {
|
||||
return (
|
||||
<SelectItem value={val} key={val}>
|
||||
{val}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sortOrder === SortOrder.DESCENDING ? (
|
||||
<IconButton
|
||||
tooltip="Descending Order"
|
||||
onClick={() => {
|
||||
setSortOrder(SortOrder.ASCENDING)
|
||||
}}
|
||||
>
|
||||
<BarsArrowDownIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
tooltip="Ascending Order"
|
||||
onClick={() => {
|
||||
setSortOrder(SortOrder.DESCENDING)
|
||||
}}
|
||||
>
|
||||
<BarsArrowUpIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className="w-full h-full rounded-md"
|
||||
onScroll={onScroll}
|
||||
ref={onRefChange}
|
||||
>
|
||||
<PhotoAlbum
|
||||
layout={layout}
|
||||
photos={photos}
|
||||
spacing={12}
|
||||
padding={0}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
71
web_app/src/components/FileSelect.tsx
Normal file
71
web_app/src/components/FileSelect.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { useState } from "react"
|
||||
import useResolution from "@/hooks/useResolution"
|
||||
|
||||
type FileSelectProps = {
|
||||
onSelection: (file: File) => void
|
||||
}
|
||||
|
||||
export default function FileSelect(props: FileSelectProps) {
|
||||
const { onSelection } = props
|
||||
|
||||
const [uploadElemId] = useState(`file-upload-${Math.random().toString()}`)
|
||||
|
||||
const resolution = useResolution()
|
||||
|
||||
function onFileSelected(file: File) {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
// Skip non-image files
|
||||
const isImage = file.type.match("image.*")
|
||||
if (!isImage) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Check if file is larger than 20mb
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
throw new Error("file too large")
|
||||
}
|
||||
onSelection(file)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line
|
||||
alert(`error: ${(e as any).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute flex w-screen h-screen justify-center items-center ">
|
||||
<label
|
||||
htmlFor={uploadElemId}
|
||||
className="grid cursor-pointer border-[2px] border-[dashed] rounded-lg min-w-[600px] hover:cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
<div
|
||||
className="grid p-16 w-full h-full"
|
||||
onDragOver={(ev) => {
|
||||
ev.stopPropagation()
|
||||
ev.preventDefault()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="hidden"
|
||||
id={uploadElemId}
|
||||
name={uploadElemId}
|
||||
type="file"
|
||||
onChange={(ev) => {
|
||||
const file = ev.currentTarget.files?.[0]
|
||||
if (file) {
|
||||
onFileSelected(file)
|
||||
}
|
||||
}}
|
||||
accept="image/png, image/jpeg"
|
||||
/>
|
||||
<p className="text-center">
|
||||
{resolution === "desktop"
|
||||
? "Click here or drag an image file"
|
||||
: "Tap here to load your picture"}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
173
web_app/src/components/Header.tsx
Normal file
173
web_app/src/components/Header.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { FolderIcon, PhotoIcon } from "@heroicons/react/24/outline"
|
||||
import { PlayIcon } from "@radix-ui/react-icons"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { useRecoilState, useRecoilValue } from "recoil"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import {
|
||||
enableFileManagerState,
|
||||
fileState,
|
||||
isInpaintingState,
|
||||
isPix2PixState,
|
||||
isSDState,
|
||||
maskState,
|
||||
runManuallyState,
|
||||
showFileManagerState,
|
||||
} from "@/lib/store"
|
||||
import { Button, IconButton, ImageUploadButton } from "@/components/ui/button"
|
||||
import Shortcuts from "@/components/Shortcuts"
|
||||
// import SettingIcon from "../Settings/SettingIcon"
|
||||
// import PromptInput from "./PromptInput"
|
||||
// import CoffeeIcon from '../CoffeeIcon/CoffeeIcon'
|
||||
import emitter, {
|
||||
DREAM_BUTTON_MOUSE_ENTER,
|
||||
DREAM_BUTTON_MOUSE_LEAVE,
|
||||
EVENT_CUSTOM_MASK,
|
||||
RERUN_LAST_MASK,
|
||||
} from "@/lib/event"
|
||||
import { useImage } from "@/hooks/useImage"
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"
|
||||
import PromptInput from "./PromptInput"
|
||||
import { RotateCw } from "lucide-react"
|
||||
import FileManager from "./FileManager"
|
||||
import { getMediaFile } from "@/lib/api"
|
||||
|
||||
const Header = () => {
|
||||
const isInpainting = useRecoilValue(isInpaintingState)
|
||||
const [file, setFile] = useRecoilState(fileState)
|
||||
const [mask, setMask] = useRecoilState(maskState)
|
||||
const [maskImage, maskImageLoaded] = useImage(mask)
|
||||
const isSD = useRecoilValue(isSDState)
|
||||
const isPix2Pix = useRecoilValue(isPix2PixState)
|
||||
const runManually = useRecoilValue(runManuallyState)
|
||||
const [openMaskPopover, setOpenMaskPopover] = useState(false)
|
||||
const enableFileManager = useRecoilValue(enableFileManagerState)
|
||||
|
||||
const handleRerunLastMask = useCallback(() => {
|
||||
emitter.emit(RERUN_LAST_MASK)
|
||||
}, [])
|
||||
|
||||
const onRerunMouseEnter = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_ENTER)
|
||||
}
|
||||
|
||||
const onRerunMouseLeave = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_LEAVE)
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
"r",
|
||||
() => {
|
||||
if (!isInpainting) {
|
||||
handleRerunLastMask()
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isInpainting, handleRerunLastMask]
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="h-[60px] px-6 py-4 absolute top-[0] flex justify-between items-center w-full z-20 backdrop-filter backdrop-blur-md border-b">
|
||||
<div className="flex items-center">
|
||||
{enableFileManager ? (
|
||||
<FileManager
|
||||
photoWidth={512}
|
||||
onPhotoClick={async (tab: string, filename: string) => {
|
||||
const newFile = await getMediaFile(tab, filename)
|
||||
setFile(newFile)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<ImageUploadButton
|
||||
disabled={isInpainting}
|
||||
tooltip="Upload image"
|
||||
onFileUpload={(file) => {
|
||||
setFile(file)
|
||||
}}
|
||||
>
|
||||
<PhotoIcon />
|
||||
</ImageUploadButton>
|
||||
|
||||
<div
|
||||
style={{
|
||||
visibility: file ? "visible" : "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ImageUploadButton
|
||||
disabled={isInpainting}
|
||||
tooltip="Upload custom mask"
|
||||
onFileUpload={(file) => {
|
||||
setMask(file)
|
||||
console.info("Send custom mask")
|
||||
if (!runManually) {
|
||||
emitter.emit(EVENT_CUSTOM_MASK, { mask: file })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>M</div>
|
||||
</ImageUploadButton>
|
||||
|
||||
{mask ? (
|
||||
<Popover open={openMaskPopover}>
|
||||
<PopoverTrigger
|
||||
className="btn-primary side-panel-trigger"
|
||||
onMouseEnter={() => setOpenMaskPopover(true)}
|
||||
onMouseLeave={() => setOpenMaskPopover(false)}
|
||||
style={{
|
||||
visibility: mask ? "visible" : "hidden",
|
||||
outline: "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (mask) {
|
||||
emitter.emit(EVENT_CUSTOM_MASK, { mask })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconButton tooltip="Run custom mask">
|
||||
<PlayIcon />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
{maskImageLoaded ? (
|
||||
<img src={maskImage.src} alt="Custom mask" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
disabled={isInpainting}
|
||||
tooltip="Rerun last mask"
|
||||
onClick={handleRerunLastMask}
|
||||
onMouseEnter={onRerunMouseEnter}
|
||||
onMouseLeave={onRerunMouseLeave}
|
||||
>
|
||||
<RotateCw />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSD ? <PromptInput /> : <></>}
|
||||
|
||||
<div className="header-icons-wrapper">
|
||||
{/* <CoffeeIcon /> */}
|
||||
<div className="header-icons">
|
||||
<Shortcuts />
|
||||
{/* <SettingIcon /> */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
66
web_app/src/components/PromptInput.tsx
Normal file
66
web_app/src/components/PromptInput.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React, { FormEvent } from "react"
|
||||
import { useRecoilState, useRecoilValue } from "recoil"
|
||||
import emitter, {
|
||||
DREAM_BUTTON_MOUSE_ENTER,
|
||||
DREAM_BUTTON_MOUSE_LEAVE,
|
||||
EVENT_PROMPT,
|
||||
} from "@/lib/event"
|
||||
import { appState, isInpaintingState, propmtState } from "@/lib/store"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
|
||||
const PromptInput = () => {
|
||||
const app = useRecoilValue(appState)
|
||||
const [prompt, setPrompt] = useRecoilState(propmtState)
|
||||
const isInpainting = useRecoilValue(isInpaintingState)
|
||||
|
||||
const handleOnInput = (evt: FormEvent<HTMLInputElement>) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as HTMLInputElement
|
||||
setPrompt(target.value)
|
||||
}
|
||||
|
||||
const handleRepaintClick = () => {
|
||||
if (prompt.length !== 0 && !app.isInpainting) {
|
||||
emitter.emit(EVENT_PROMPT)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isInpainting) {
|
||||
handleRepaintClick()
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseEnter = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_ENTER)
|
||||
}
|
||||
|
||||
const onMouseLeave = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_LEAVE)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
className="min-w-[600px]"
|
||||
value={prompt}
|
||||
onInput={handleOnInput}
|
||||
onKeyUp={onKeyUp}
|
||||
placeholder="I want to repaint of..."
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRepaintClick}
|
||||
disabled={prompt.length === 0 || app.isInpainting}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
Dream
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptInput
|
97
web_app/src/components/Shortcuts.tsx
Normal file
97
web_app/src/components/Shortcuts.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { Keyboard } from "lucide-react"
|
||||
import useHotKey from "@/hooks/useHotkey"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { useToggle } from "@uidotdev/usehooks"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog"
|
||||
|
||||
interface ShortcutProps {
|
||||
content: string
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
function ShortCut(props: ShortcutProps) {
|
||||
const { content, keys } = props
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="shortcut-description">{content}</div>
|
||||
<div className="flex gap-[8px]">
|
||||
{keys.map((k) => (
|
||||
// TODO: 优化快捷键显示
|
||||
<div className="border px-2 py-1 rounded-lg" key={k}>
|
||||
{k}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isMac = function () {
|
||||
return /macintosh|mac os x/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
const isWindows = function () {
|
||||
return /windows|win32/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
const CmdOrCtrl = () => {
|
||||
return isMac() ? "Cmd" : "Ctrl"
|
||||
}
|
||||
|
||||
export function Shortcuts() {
|
||||
const [open, toggleOpen] = useToggle(false)
|
||||
|
||||
useHotKey("h", () => {
|
||||
toggleOpen()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={toggleOpen}>
|
||||
<DialogTrigger>
|
||||
<IconButton tooltip="Hotkeys">
|
||||
<Keyboard />
|
||||
</IconButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Hotkeys</DialogTitle>
|
||||
<DialogDescription className="flex gap-2 flex-col pt-4">
|
||||
<ShortCut content="Pan" keys={["Space + Drag"]} />
|
||||
<ShortCut content="Reset Zoom/Pan" keys={["Esc"]} />
|
||||
<ShortCut content="Decrease Brush Size" keys={["["]} />
|
||||
<ShortCut content="Increase Brush Size" keys={["]"]} />
|
||||
<ShortCut content="View Original Image" keys={["Hold Tab"]} />
|
||||
<ShortCut
|
||||
content="Multi-Stroke Drawing"
|
||||
keys={[`Hold ${CmdOrCtrl()}`]}
|
||||
/>
|
||||
<ShortCut content="Cancel Drawing" keys={["Esc"]} />
|
||||
|
||||
<ShortCut content="Rerun last mask" keys={["R"]} />
|
||||
<ShortCut content="Undo" keys={[CmdOrCtrl(), "Z"]} />
|
||||
<ShortCut content="Redo" keys={[CmdOrCtrl(), "Shift", "Z"]} />
|
||||
<ShortCut content="Copy Result" keys={[CmdOrCtrl(), "C"]} />
|
||||
<ShortCut content="Paste Image" keys={[CmdOrCtrl(), "V"]} />
|
||||
<ShortCut
|
||||
content="Trigger Manually Inpainting"
|
||||
keys={["Shift", "R"]}
|
||||
/>
|
||||
<ShortCut content="Toggle Hotkeys Dialog" keys={["H"]} />
|
||||
<ShortCut content="Toggle Settings Dialog" keys={["S"]} />
|
||||
<ShortCut content="Toggle File Manager" keys={["F"]} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Shortcuts
|
114
web_app/src/components/Workspace.tsx
Normal file
114
web_app/src/components/Workspace.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React, { useEffect } from "react"
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"
|
||||
import Editor from "./Editor"
|
||||
// import SettingModal from "./Settings/SettingsModal"
|
||||
// import Toast from "./shared/Toast"
|
||||
import {
|
||||
AIModel,
|
||||
fileState,
|
||||
isPaintByExampleState,
|
||||
isPix2PixState,
|
||||
isSDState,
|
||||
settingState,
|
||||
showFileManagerState,
|
||||
toastState,
|
||||
} from "@/lib/store"
|
||||
import {
|
||||
currentModel,
|
||||
getMediaFile,
|
||||
modelDownloaded,
|
||||
switchModel,
|
||||
} from "@/lib/api"
|
||||
// import SidePanel from "./SidePanel/SidePanel"
|
||||
// import PESidePanel from "./SidePanel/PESidePanel"
|
||||
// import FileManager from "./FileManager/FileManager"
|
||||
// import P2PSidePanel from "./SidePanel/P2PSidePanel"
|
||||
// import Plugins from "./Plugins/Plugins"
|
||||
// import Flex from "./shared/Layout"
|
||||
// import ImageSize from "./ImageSize/ImageSize"
|
||||
|
||||
const Workspace = () => {
|
||||
const setFile = useSetRecoilState(fileState)
|
||||
const [settings, setSettingState] = useRecoilState(settingState)
|
||||
const [toastVal, setToastState] = useRecoilState(toastState)
|
||||
const isSD = useRecoilValue(isSDState)
|
||||
const isPaintByExample = useRecoilValue(isPaintByExampleState)
|
||||
const isPix2Pix = useRecoilValue(isPix2PixState)
|
||||
|
||||
const onSettingClose = async () => {
|
||||
const curModel = await currentModel().then((res) => res.text())
|
||||
if (curModel === settings.model) {
|
||||
return
|
||||
}
|
||||
const downloaded = await modelDownloaded(settings.model).then((res) =>
|
||||
res.text()
|
||||
)
|
||||
|
||||
const { model } = settings
|
||||
|
||||
let loadingMessage = `Switching to ${model} model`
|
||||
let loadingDuration = 3000
|
||||
if (downloaded === "False") {
|
||||
loadingMessage = `Downloading ${model} model, this may take a while`
|
||||
loadingDuration = 9999999999
|
||||
}
|
||||
|
||||
setToastState({
|
||||
open: true,
|
||||
desc: loadingMessage,
|
||||
state: "loading",
|
||||
duration: loadingDuration,
|
||||
})
|
||||
|
||||
switchModel(model)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
setToastState({
|
||||
open: true,
|
||||
desc: `Switch to ${model} model success`,
|
||||
state: "success",
|
||||
duration: 3000,
|
||||
})
|
||||
} else {
|
||||
throw new Error("Server error")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setToastState({
|
||||
open: true,
|
||||
desc: `Switch to ${model} model failed`,
|
||||
state: "error",
|
||||
duration: 3000,
|
||||
})
|
||||
setSettingState((old) => {
|
||||
return { ...old, model: curModel as AIModel }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
currentModel()
|
||||
.then((res) => res.text())
|
||||
.then((model) => {
|
||||
setSettingState((old) => {
|
||||
return { ...old, model: model as AIModel }
|
||||
})
|
||||
})
|
||||
}, [setSettingState])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* {isSD ? <SidePanel /> : <></>}
|
||||
{isPaintByExample ? <PESidePanel /> : <></>}
|
||||
{isPix2Pix ? <P2PSidePanel /> : <></>}
|
||||
<Flex style={{ position: "absolute", top: 68, left: 24, gap: 12 }}>
|
||||
<Plugins />
|
||||
<ImageSize />
|
||||
</Flex>
|
||||
{/* <SettingModal onClose={onSettingClose} /> */}
|
||||
<Editor />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Workspace
|
73
web_app/src/components/theme-provider.tsx
Normal file
73
web_app/src/components/theme-provider.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
55
web_app/src/components/ui/accordion.tsx
Normal file
55
web_app/src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
123
web_app/src/components/ui/button.tsx
Normal file
123
web_app/src/components/ui/button.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "./input"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export interface IconButtonProps extends ButtonProps {
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
const IconButton = (props: IconButtonProps) => {
|
||||
const { tooltip, children, ...rest } = props
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" {...rest} asChild>
|
||||
<div className="p-[8px]">{children}</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface UploadButtonProps extends IconButtonProps {
|
||||
onFileUpload: (file: File) => void
|
||||
}
|
||||
|
||||
const ImageUploadButton = (props: UploadButtonProps) => {
|
||||
const { onFileUpload, children, ...rest } = props
|
||||
|
||||
const [uploadElemId] = React.useState(
|
||||
`file-upload-${Math.random().toString()}`
|
||||
)
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFile = ev.currentTarget.files?.[0]
|
||||
if (newFile) {
|
||||
onFileUpload(newFile)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={uploadElemId}>
|
||||
<IconButton {...rest}>{children}</IconButton>
|
||||
</label>
|
||||
<Input
|
||||
style={{ display: "none" }}
|
||||
id={uploadElemId}
|
||||
name={uploadElemId}
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
accept="image/png, image/jpeg"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, IconButton, ImageUploadButton, buttonVariants }
|
120
web_app/src/components/ui/dialog.tsx
Normal file
120
web_app/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 flex flex-col w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
25
web_app/src/components/ui/input.tsx
Normal file
25
web_app/src/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
24
web_app/src/components/ui/label.tsx
Normal file
24
web_app/src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
29
web_app/src/components/ui/popover.tsx
Normal file
29
web_app/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
46
web_app/src/components/ui/scroll-area.tsx
Normal file
46
web_app/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
162
web_app/src/components/ui/select.tsx
Normal file
162
web_app/src/components/ui/select.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
26
web_app/src/components/ui/slider.tsx
Normal file
26
web_app/src/components/ui/slider.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
27
web_app/src/components/ui/switch.tsx
Normal file
27
web_app/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
53
web_app/src/components/ui/tabs.tsx
Normal file
53
web_app/src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
24
web_app/src/components/ui/textarea.tsx
Normal file
24
web_app/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
127
web_app/src/components/ui/toast.tsx
Normal file
127
web_app/src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
33
web_app/src/components/ui/toaster.tsx
Normal file
33
web_app/src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
43
web_app/src/components/ui/toggle.tsx
Normal file
43
web_app/src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-3",
|
||||
sm: "h-8 px-2",
|
||||
lg: "h-10 px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
28
web_app/src/components/ui/tooltip.tsx
Normal file
28
web_app/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
192
web_app/src/components/ui/use-toast.ts
Normal file
192
web_app/src/components/ui/use-toast.ts
Normal file
@ -0,0 +1,192 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
100
web_app/src/globals.css
Normal file
100
web_app/src/globals.css
Normal file
@ -0,0 +1,100 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.react-transform-wrapper {
|
||||
display: grid !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.react-photo-album {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.react-photo-album--photo {
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
|
||||
transition: transform 0.25s, visibility 0.25s ease-in;
|
||||
}
|
||||
|
||||
.react-photo-album--photo:hover {
|
||||
border: 1px solid var(--border);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 224 71.4% 4.1%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 224 71.4% 4.1%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 224 71.4% 4.1%;
|
||||
|
||||
--primary: 220.9 39.3% 11%;
|
||||
--primary-foreground: 210 20% 98%;
|
||||
|
||||
--secondary: 220 14.3% 95.9%;
|
||||
--secondary-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--muted: 220 14.3% 95.9%;
|
||||
--muted-foreground: 220 8.9% 46.1%;
|
||||
|
||||
--accent: 220 14.3% 95.9%;
|
||||
--accent-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 224 71.4% 4.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71.4% 4.1%;
|
||||
--foreground: 210 20% 98%;
|
||||
|
||||
--card: 224 71.4% 4.1%;
|
||||
--card-foreground: 210 20% 98%;
|
||||
|
||||
--popover: 224 71.4% 4.1%;
|
||||
--popover-foreground: 210 20% 98%;
|
||||
|
||||
--primary: 210 20% 98%;
|
||||
--primary-foreground: 220.9 39.3% 11%;
|
||||
|
||||
--secondary: 215 27.9% 16.9%;
|
||||
--secondary-foreground: 210 20% 98%;
|
||||
|
||||
--muted: 215 27.9% 16.9%;
|
||||
--muted-foreground: 217.9 10.6% 64.9%;
|
||||
|
||||
--accent: 215 27.9% 16.9%;
|
||||
--accent-foreground: 210 20% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 20% 98%;
|
||||
|
||||
--border: 215 27.9% 16.9%;
|
||||
--input: 215 27.9% 16.9%;
|
||||
--ring: 216 12.2% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
33
web_app/src/hooks/useAsyncMemo.tsx
Normal file
33
web_app/src/hooks/useAsyncMemo.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { DependencyList, useEffect, useState } from 'react'
|
||||
|
||||
export function useAsyncMemo<T>(
|
||||
factory: () => Promise<T> | undefined | null,
|
||||
deps: DependencyList
|
||||
): T | undefined
|
||||
export function useAsyncMemo<T>(
|
||||
factory: () => Promise<T> | undefined | null,
|
||||
deps: DependencyList,
|
||||
initial: T
|
||||
): T
|
||||
export function useAsyncMemo<T>(
|
||||
factory: () => Promise<T> | undefined | null,
|
||||
deps: DependencyList,
|
||||
initial?: T
|
||||
) {
|
||||
const [val, setVal] = useState<T | undefined>(initial)
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
const promise = factory()
|
||||
if (promise === undefined || promise === null) return
|
||||
promise.then(v => {
|
||||
if (!cancel) {
|
||||
setVal(v)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, deps)
|
||||
return val
|
||||
}
|
22
web_app/src/hooks/useHotkey.tsx
Normal file
22
web_app/src/hooks/useHotkey.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Options, useHotkeys } from "react-hotkeys-hook"
|
||||
import { useRecoilValue } from "recoil"
|
||||
import { appState } from "@/lib/store"
|
||||
|
||||
const useHotKey = (
|
||||
keys: string,
|
||||
callback: any,
|
||||
options?: Options,
|
||||
deps?: any[]
|
||||
) => {
|
||||
const app = useRecoilValue(appState)
|
||||
|
||||
const ref = useHotkeys(
|
||||
keys,
|
||||
callback,
|
||||
{ ...options, enabled: !app.disableShortCuts },
|
||||
deps
|
||||
)
|
||||
return ref
|
||||
}
|
||||
|
||||
export default useHotKey
|
24
web_app/src/hooks/useImage.tsx
Normal file
24
web_app/src/hooks/useImage.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
function useImage(file?: File): [HTMLImageElement, boolean] {
|
||||
const [image] = useState(new Image())
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (file === undefined) {
|
||||
return
|
||||
}
|
||||
image.onload = () => {
|
||||
setIsLoaded(true)
|
||||
}
|
||||
setIsLoaded(false)
|
||||
image.src = URL.createObjectURL(file)
|
||||
return () => {
|
||||
image.onload = null
|
||||
}
|
||||
}, [file, image])
|
||||
|
||||
return [image, isLoaded]
|
||||
}
|
||||
|
||||
export { useImage }
|
34
web_app/src/hooks/useInputImage.tsx
Normal file
34
web_app/src/hooks/useInputImage.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { API_ENDPOINT } from "@/lib/api"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
||||
export default function useInputImage() {
|
||||
const [inputImage, setInputImage] = useState<File>()
|
||||
|
||||
const fetchInputImage = useCallback(() => {
|
||||
const headers = new Headers()
|
||||
headers.append("pragma", "no-cache")
|
||||
headers.append("cache-control", "no-cache")
|
||||
|
||||
fetch(`${API_ENDPOINT}/inputimage`, { headers }).then(async (res) => {
|
||||
const filename = res.headers
|
||||
.get("content-disposition")
|
||||
?.split("filename=")[1]
|
||||
.split(";")[0]
|
||||
|
||||
const data = await res.blob()
|
||||
if (data && data.type.startsWith("image")) {
|
||||
const userInput = new File(
|
||||
[data],
|
||||
filename !== undefined ? filename : "inputImage"
|
||||
)
|
||||
setInputImage(userInput)
|
||||
}
|
||||
})
|
||||
}, [setInputImage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInputImage()
|
||||
}, [fetchInputImage])
|
||||
|
||||
return inputImage
|
||||
}
|
31
web_app/src/hooks/useResolution.tsx
Normal file
31
web_app/src/hooks/useResolution.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
const useResolution = () => {
|
||||
const [width, setWidth] = useState(window.innerWidth)
|
||||
|
||||
const windowSizeHandler = useCallback(() => {
|
||||
setWidth(window.innerWidth)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', windowSizeHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', windowSizeHandler)
|
||||
}
|
||||
})
|
||||
|
||||
if (width < 768) {
|
||||
return 'mobile'
|
||||
}
|
||||
|
||||
if (width >= 768 && width < 1224) {
|
||||
return 'tablet'
|
||||
}
|
||||
|
||||
if (width >= 1224) {
|
||||
return 'desktop'
|
||||
}
|
||||
}
|
||||
|
||||
export default useResolution
|
270
web_app/src/lib/api.ts
Normal file
270
web_app/src/lib/api.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { PluginName } from "@/lib/types"
|
||||
import { ControlNetMethodMap, Rect, Settings } from "@/lib/store"
|
||||
import { dataURItoBlob, loadImage, srcToFile } from "@/lib/utils"
|
||||
|
||||
export const API_ENDPOINT = import.meta.env.VITE_BACKEND
|
||||
? import.meta.env.VITE_BACKEND
|
||||
: ""
|
||||
|
||||
export default async function inpaint(
|
||||
imageFile: File,
|
||||
settings: Settings,
|
||||
croperRect: Rect,
|
||||
prompt?: string,
|
||||
negativePrompt?: string,
|
||||
seed?: number,
|
||||
maskBase64?: string,
|
||||
customMask?: File,
|
||||
paintByExampleImage?: File
|
||||
) {
|
||||
// 1080, 2000, Original
|
||||
const fd = new FormData()
|
||||
fd.append("image", imageFile)
|
||||
if (maskBase64 !== undefined) {
|
||||
fd.append("mask", dataURItoBlob(maskBase64))
|
||||
} else if (customMask !== undefined) {
|
||||
fd.append("mask", customMask)
|
||||
}
|
||||
|
||||
const hdSettings = settings.hdSettings[settings.model]
|
||||
fd.append("ldmSteps", settings.ldmSteps.toString())
|
||||
fd.append("ldmSampler", settings.ldmSampler.toString())
|
||||
fd.append("zitsWireframe", settings.zitsWireframe.toString())
|
||||
fd.append("hdStrategy", hdSettings.hdStrategy)
|
||||
fd.append("hdStrategyCropMargin", hdSettings.hdStrategyCropMargin.toString())
|
||||
fd.append(
|
||||
"hdStrategyCropTrigerSize",
|
||||
hdSettings.hdStrategyCropTrigerSize.toString()
|
||||
)
|
||||
fd.append(
|
||||
"hdStrategyResizeLimit",
|
||||
hdSettings.hdStrategyResizeLimit.toString()
|
||||
)
|
||||
|
||||
fd.append("prompt", prompt === undefined ? "" : prompt)
|
||||
fd.append(
|
||||
"negativePrompt",
|
||||
negativePrompt === undefined ? "" : negativePrompt
|
||||
)
|
||||
fd.append("croperX", croperRect.x.toString())
|
||||
fd.append("croperY", croperRect.y.toString())
|
||||
fd.append("croperHeight", croperRect.height.toString())
|
||||
fd.append("croperWidth", croperRect.width.toString())
|
||||
fd.append("useCroper", settings.showCroper ? "true" : "false")
|
||||
|
||||
fd.append("sdMaskBlur", settings.sdMaskBlur.toString())
|
||||
fd.append("sdStrength", settings.sdStrength.toString())
|
||||
fd.append("sdSteps", settings.sdSteps.toString())
|
||||
fd.append("sdGuidanceScale", settings.sdGuidanceScale.toString())
|
||||
fd.append("sdSampler", settings.sdSampler.toString())
|
||||
fd.append("sdSeed", seed ? seed.toString() : "-1")
|
||||
fd.append("sdMatchHistograms", settings.sdMatchHistograms ? "true" : "false")
|
||||
fd.append("sdScale", (settings.sdScale / 100).toString())
|
||||
|
||||
fd.append("cv2Radius", settings.cv2Radius.toString())
|
||||
fd.append("cv2Flag", settings.cv2Flag.toString())
|
||||
|
||||
fd.append("paintByExampleSteps", settings.paintByExampleSteps.toString())
|
||||
fd.append(
|
||||
"paintByExampleGuidanceScale",
|
||||
settings.paintByExampleGuidanceScale.toString()
|
||||
)
|
||||
fd.append("paintByExampleSeed", seed ? seed.toString() : "-1")
|
||||
fd.append(
|
||||
"paintByExampleMaskBlur",
|
||||
settings.paintByExampleMaskBlur.toString()
|
||||
)
|
||||
fd.append(
|
||||
"paintByExampleMatchHistograms",
|
||||
settings.paintByExampleMatchHistograms ? "true" : "false"
|
||||
)
|
||||
// TODO: resize image's shortest_edge to 224 before pass to backend, save network time?
|
||||
// https://huggingface.co/docs/transformers/model_doc/clip#transformers.CLIPImageProcessor
|
||||
if (paintByExampleImage) {
|
||||
fd.append("paintByExampleImage", paintByExampleImage)
|
||||
}
|
||||
|
||||
// InstructPix2Pix
|
||||
fd.append("p2pSteps", settings.p2pSteps.toString())
|
||||
fd.append("p2pImageGuidanceScale", settings.p2pImageGuidanceScale.toString())
|
||||
fd.append("p2pGuidanceScale", settings.p2pGuidanceScale.toString())
|
||||
|
||||
// ControlNet
|
||||
fd.append(
|
||||
"controlnet_conditioning_scale",
|
||||
settings.controlnetConditioningScale.toString()
|
||||
)
|
||||
fd.append(
|
||||
"controlnet_method",
|
||||
ControlNetMethodMap[settings.controlnetMethod.toString()]
|
||||
)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_ENDPOINT}/inpaint`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const newSeed = res.headers.get("x-seed")
|
||||
return { blob: URL.createObjectURL(blob), seed: newSeed }
|
||||
}
|
||||
const errMsg = await res.text()
|
||||
throw new Error(errMsg)
|
||||
} catch (error) {
|
||||
throw new Error(`Something went wrong: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerConfig() {
|
||||
return fetch(`${API_ENDPOINT}/server_config`, {
|
||||
method: "GET",
|
||||
})
|
||||
}
|
||||
|
||||
export function switchModel(name: string) {
|
||||
const fd = new FormData()
|
||||
fd.append("name", name)
|
||||
return fetch(`${API_ENDPOINT}/model`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
})
|
||||
}
|
||||
|
||||
export function currentModel() {
|
||||
return fetch(`${API_ENDPOINT}/model`, {
|
||||
method: "GET",
|
||||
})
|
||||
}
|
||||
|
||||
export function isDesktop() {
|
||||
return fetch(`${API_ENDPOINT}/is_desktop`, {
|
||||
method: "GET",
|
||||
})
|
||||
}
|
||||
|
||||
export function modelDownloaded(name: string) {
|
||||
return fetch(`${API_ENDPOINT}/model_downloaded/${name}`, {
|
||||
method: "GET",
|
||||
})
|
||||
}
|
||||
|
||||
export async function runPlugin(
|
||||
name: string,
|
||||
imageFile: File,
|
||||
upscale?: number,
|
||||
maskFile?: File | null,
|
||||
clicks?: number[][]
|
||||
) {
|
||||
const fd = new FormData()
|
||||
fd.append("name", name)
|
||||
fd.append("image", imageFile)
|
||||
if (upscale) {
|
||||
fd.append("upscale", upscale.toString())
|
||||
}
|
||||
if (clicks) {
|
||||
fd.append("clicks", JSON.stringify(clicks))
|
||||
}
|
||||
if (maskFile) {
|
||||
fd.append("mask", maskFile)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_ENDPOINT}/run_plugin`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
})
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
return { blob: URL.createObjectURL(blob) }
|
||||
}
|
||||
const errMsg = await res.text()
|
||||
throw new Error(errMsg)
|
||||
} catch (error) {
|
||||
throw new Error(`Something went wrong: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMediaFile(tab: string, filename: string) {
|
||||
const res = await fetch(
|
||||
`${API_ENDPOINT}/media/${tab}/${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const file = new File([blob], filename)
|
||||
return file
|
||||
}
|
||||
const errMsg = await res.text()
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
export async function getMedias(tab: string) {
|
||||
const res = await fetch(`${API_ENDPOINT}/medias/${tab}`, {
|
||||
method: "GET",
|
||||
})
|
||||
if (res.ok) {
|
||||
const filenames = await res.json()
|
||||
return filenames
|
||||
}
|
||||
const errMsg = await res.text()
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
export async function downloadToOutput(
|
||||
image: HTMLImageElement,
|
||||
filename: string,
|
||||
mimeType: string
|
||||
) {
|
||||
const file = await srcToFile(image.src, filename, mimeType)
|
||||
const fd = new FormData()
|
||||
fd.append("image", file)
|
||||
fd.append("filename", filename)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_ENDPOINT}/save_image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errMsg = await res.text()
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Something went wrong: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeGif(
|
||||
originFile: File,
|
||||
cleanImage: HTMLImageElement,
|
||||
filename: string,
|
||||
mimeType: string
|
||||
) {
|
||||
const cleanFile = await srcToFile(cleanImage.src, filename, mimeType)
|
||||
const fd = new FormData()
|
||||
fd.append("name", PluginName.MakeGIF)
|
||||
fd.append("image", originFile)
|
||||
fd.append("clean_img", cleanFile)
|
||||
fd.append("filename", filename)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_ENDPOINT}/run_plugin`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const errMsg = await res.text()
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
const newImage = new Image()
|
||||
await loadImage(newImage, URL.createObjectURL(blob))
|
||||
return newImage
|
||||
} catch (error) {
|
||||
throw new Error(`Something went wrong: ${error}`)
|
||||
}
|
||||
}
|
22
web_app/src/lib/event.ts
Normal file
22
web_app/src/lib/event.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import mitt from "mitt"
|
||||
|
||||
export const EVENT_PROMPT = "prompt"
|
||||
|
||||
export const EVENT_CUSTOM_MASK = "custom_mask"
|
||||
export interface CustomMaskEventData {
|
||||
mask: File
|
||||
}
|
||||
|
||||
export const EVENT_PAINT_BY_EXAMPLE = "paint_by_example"
|
||||
export interface PaintByExampleEventData {
|
||||
image: File
|
||||
}
|
||||
|
||||
export const RERUN_LAST_MASK = "rerun_last_mask"
|
||||
|
||||
export const DREAM_BUTTON_MOUSE_ENTER = "dream_button_mouse_enter"
|
||||
export const DREAM_BUTTON_MOUSE_LEAVE = "dream_btoon_mouse_leave"
|
||||
|
||||
const emitter = mitt()
|
||||
|
||||
export default emitter
|
902
web_app/src/lib/store.ts
Normal file
902
web_app/src/lib/store.ts
Normal file
@ -0,0 +1,902 @@
|
||||
import { atom, selector } from "recoil"
|
||||
import _ from "lodash"
|
||||
|
||||
export enum HDStrategy {
|
||||
ORIGINAL = "Original",
|
||||
RESIZE = "Resize",
|
||||
CROP = "Crop",
|
||||
}
|
||||
|
||||
export enum LDMSampler {
|
||||
ddim = "ddim",
|
||||
plms = "plms",
|
||||
}
|
||||
|
||||
function strEnum<T extends string>(o: Array<T>): { [K in T]: K } {
|
||||
return o.reduce((res, key) => {
|
||||
res[key] = key
|
||||
return res
|
||||
}, Object.create(null))
|
||||
}
|
||||
|
||||
export enum AIModel {
|
||||
LAMA = "lama",
|
||||
LDM = "ldm",
|
||||
ZITS = "zits",
|
||||
MAT = "mat",
|
||||
FCF = "fcf",
|
||||
SD15 = "sd1.5",
|
||||
ANYTHING4 = "anything4",
|
||||
REALISTIC_VISION_1_4 = "realisticVision1.4",
|
||||
SD2 = "sd2",
|
||||
CV2 = "cv2",
|
||||
Mange = "manga",
|
||||
PAINT_BY_EXAMPLE = "paint_by_example",
|
||||
PIX2PIX = "instruct_pix2pix",
|
||||
KANDINSKY22 = "kandinsky2.2",
|
||||
}
|
||||
|
||||
export enum ControlNetMethod {
|
||||
canny = "canny",
|
||||
inpaint = "inpaint",
|
||||
openpose = "openpose",
|
||||
depth = "depth",
|
||||
}
|
||||
|
||||
export const ControlNetMethodMap: any = {
|
||||
canny: "control_v11p_sd15_canny",
|
||||
inpaint: "control_v11p_sd15_inpaint",
|
||||
openpose: "control_v11p_sd15_openpose",
|
||||
depth: "control_v11f1p_sd15_depth",
|
||||
}
|
||||
|
||||
export const ControlNetMethodMap2: any = {
|
||||
control_v11p_sd15_canny: "canny",
|
||||
control_v11p_sd15_inpaint: "inpaint",
|
||||
control_v11p_sd15_openpose: "openpose",
|
||||
control_v11f1p_sd15_depth: "depth",
|
||||
}
|
||||
|
||||
export const maskState = atom<File | undefined>({
|
||||
key: "maskState",
|
||||
default: undefined,
|
||||
})
|
||||
|
||||
export const paintByExampleImageState = atom<File | undefined>({
|
||||
key: "paintByExampleImageState",
|
||||
default: undefined,
|
||||
})
|
||||
|
||||
export interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
file: File | undefined
|
||||
imageHeight: number
|
||||
imageWidth: number
|
||||
disableShortCuts: boolean
|
||||
isInpainting: boolean
|
||||
isDisableModelSwitch: boolean
|
||||
isEnableAutoSaving: boolean
|
||||
isInteractiveSeg: boolean
|
||||
isInteractiveSegRunning: boolean
|
||||
interactiveSegClicks: number[][]
|
||||
enableFileManager: boolean
|
||||
gifImage: HTMLImageElement | undefined
|
||||
brushSize: number
|
||||
isControlNet: boolean
|
||||
controlNetMethod: string
|
||||
plugins: string[]
|
||||
isPluginRunning: boolean
|
||||
}
|
||||
|
||||
export const appState = atom<AppState>({
|
||||
key: "appState",
|
||||
default: {
|
||||
file: undefined,
|
||||
imageHeight: 0,
|
||||
imageWidth: 0,
|
||||
disableShortCuts: false,
|
||||
isInpainting: false,
|
||||
isDisableModelSwitch: false,
|
||||
isEnableAutoSaving: false,
|
||||
isInteractiveSeg: false,
|
||||
isInteractiveSegRunning: false,
|
||||
interactiveSegClicks: [],
|
||||
enableFileManager: false,
|
||||
gifImage: undefined,
|
||||
brushSize: 40,
|
||||
isControlNet: false,
|
||||
controlNetMethod: ControlNetMethod.canny,
|
||||
plugins: [],
|
||||
isPluginRunning: false,
|
||||
},
|
||||
})
|
||||
|
||||
export const propmtState = atom<string>({
|
||||
key: "promptState",
|
||||
default: "",
|
||||
})
|
||||
|
||||
export const negativePropmtState = atom<string>({
|
||||
key: "negativePromptState",
|
||||
default: "",
|
||||
})
|
||||
|
||||
export const isInpaintingState = selector({
|
||||
key: "isInpainting",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isInpainting
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isInpainting: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const isPluginRunningState = selector({
|
||||
key: "isPluginRunningState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isPluginRunning
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isPluginRunning: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const serverConfigState = selector({
|
||||
key: "serverConfigState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return {
|
||||
isControlNet: app.isControlNet,
|
||||
controlNetMethod: app.controlNetMethod,
|
||||
isDisableModelSwitchState: app.isDisableModelSwitch,
|
||||
isEnableAutoSaving: app.isEnableAutoSaving,
|
||||
enableFileManager: app.enableFileManager,
|
||||
plugins: app.plugins,
|
||||
}
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
const methodShortName = ControlNetMethodMap2[newValue.controlNetMethod]
|
||||
set(appState, { ...app, ...newValue, controlnetMethod: methodShortName })
|
||||
|
||||
const setting = get(settingState)
|
||||
set(settingState, {
|
||||
...setting,
|
||||
controlnetMethod: methodShortName,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const brushSizeState = selector({
|
||||
key: "brushSizeState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.brushSize
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, brushSize: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const imageHeightState = selector({
|
||||
key: "imageHeightState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.imageHeight
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, imageHeight: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const imageWidthState = selector({
|
||||
key: "imageWidthState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.imageWidth
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, imageWidth: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const enableFileManagerState = selector({
|
||||
key: "enableFileManagerState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.enableFileManager
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, enableFileManager: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const gifImageState = selector({
|
||||
key: "gifImageState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.gifImage
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, gifImage: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const fileState = selector({
|
||||
key: "fileState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.file
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, {
|
||||
...app,
|
||||
file: newValue,
|
||||
interactiveSegClicks: [],
|
||||
isInteractiveSeg: false,
|
||||
isInteractiveSegRunning: false,
|
||||
})
|
||||
|
||||
const setting = get(settingState)
|
||||
set(settingState, {
|
||||
...setting,
|
||||
sdScale: 100,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const isInteractiveSegState = selector({
|
||||
key: "isInteractiveSegState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isInteractiveSeg
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isInteractiveSeg: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const isInteractiveSegRunningState = selector({
|
||||
key: "isInteractiveSegRunningState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isInteractiveSegRunning
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isInteractiveSegRunning: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const isProcessingState = selector({
|
||||
key: "isProcessingState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return (
|
||||
app.isInteractiveSegRunning || app.isPluginRunning || app.isInpainting
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const interactiveSegClicksState = selector({
|
||||
key: "interactiveSegClicksState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.interactiveSegClicks
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, interactiveSegClicks: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const isDisableModelSwitchState = selector({
|
||||
key: "isDisableModelSwitchState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isDisableModelSwitch
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isDisableModelSwitch: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const isControlNetState = selector({
|
||||
key: "isControlNetState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isControlNet
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isControlNet: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const isEnableAutoSavingState = selector({
|
||||
key: "isEnableAutoSavingState",
|
||||
get: ({ get }) => {
|
||||
const app = get(appState)
|
||||
return app.isEnableAutoSaving
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const app = get(appState)
|
||||
set(appState, { ...app, isEnableAutoSaving: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const croperState = atom<Rect>({
|
||||
key: "croperState",
|
||||
default: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
})
|
||||
|
||||
export const SIDE_PANEL_TAB = strEnum(["inpainting", "outpainting"])
|
||||
export type SIDE_PANEL_TAB_TYPE = keyof typeof SIDE_PANEL_TAB
|
||||
|
||||
export interface SidePanelState {
|
||||
tab: SIDE_PANEL_TAB_TYPE
|
||||
}
|
||||
|
||||
export const sidePanelTabState = atom<SidePanelState>({
|
||||
key: "sidePanelTabState",
|
||||
default: {
|
||||
tab: SIDE_PANEL_TAB.inpainting,
|
||||
},
|
||||
})
|
||||
|
||||
export const croperX = selector({
|
||||
key: "croperX",
|
||||
get: ({ get }) => get(croperState).x,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(croperState)
|
||||
set(croperState, { ...rect, x: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const croperY = selector({
|
||||
key: "croperY",
|
||||
get: ({ get }) => get(croperState).y,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(croperState)
|
||||
set(croperState, { ...rect, y: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const croperHeight = selector({
|
||||
key: "croperHeight",
|
||||
get: ({ get }) => get(croperState).height,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(croperState)
|
||||
set(croperState, { ...rect, height: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const croperWidth = selector({
|
||||
key: "croperWidth",
|
||||
get: ({ get }) => get(croperState).width,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(croperState)
|
||||
set(croperState, { ...rect, width: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const extenderState = atom<Rect>({
|
||||
key: "extenderState",
|
||||
default: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
},
|
||||
})
|
||||
|
||||
export const extenderX = selector({
|
||||
key: "extenderX",
|
||||
get: ({ get }) => get(extenderState).x,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(extenderState)
|
||||
set(extenderState, { ...rect, x: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const extenderY = selector({
|
||||
key: "extenderY",
|
||||
get: ({ get }) => get(extenderState).y,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(extenderState)
|
||||
set(extenderState, { ...rect, y: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const extenderHeight = selector({
|
||||
key: "extenderHeight",
|
||||
get: ({ get }) => get(extenderState).height,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(extenderState)
|
||||
set(extenderState, { ...rect, height: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const extenderWidth = selector({
|
||||
key: "extenderWidth",
|
||||
get: ({ get }) => get(extenderState).width,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const rect = get(extenderState)
|
||||
set(extenderState, { ...rect, width: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
interface ToastAtomState {
|
||||
open: boolean
|
||||
desc: string
|
||||
state: ToastState
|
||||
duration: number
|
||||
}
|
||||
|
||||
export const toastState = atom<ToastAtomState>({
|
||||
key: "toastState",
|
||||
default: {
|
||||
open: false,
|
||||
desc: "",
|
||||
state: "default",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
|
||||
export const shortcutsState = atom<boolean>({
|
||||
key: "shortcutsState",
|
||||
default: false,
|
||||
})
|
||||
|
||||
export interface HDSettings {
|
||||
hdStrategy: HDStrategy
|
||||
hdStrategyResizeLimit: number
|
||||
hdStrategyCropTrigerSize: number
|
||||
hdStrategyCropMargin: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
type ModelsHDSettings = { [key in AIModel]: HDSettings }
|
||||
|
||||
export enum CV2Flag {
|
||||
INPAINT_NS = "INPAINT_NS",
|
||||
INPAINT_TELEA = "INPAINT_TELEA",
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
show: boolean
|
||||
showCroper: boolean
|
||||
downloadMask: boolean
|
||||
graduallyInpainting: boolean
|
||||
runInpaintingManually: boolean
|
||||
model: AIModel
|
||||
hdSettings: ModelsHDSettings
|
||||
|
||||
// For LDM
|
||||
ldmSteps: number
|
||||
ldmSampler: LDMSampler
|
||||
|
||||
// For ZITS
|
||||
zitsWireframe: boolean
|
||||
|
||||
// For SD
|
||||
sdMaskBlur: number
|
||||
sdMode: SDMode
|
||||
sdStrength: number
|
||||
sdSteps: number
|
||||
sdGuidanceScale: number
|
||||
sdSampler: SDSampler
|
||||
sdSeed: number
|
||||
sdSeedFixed: boolean // true: use sdSeed, false: random generate seed on backend
|
||||
sdNumSamples: number
|
||||
sdMatchHistograms: boolean
|
||||
sdScale: number
|
||||
|
||||
// For OpenCV2
|
||||
cv2Radius: number
|
||||
cv2Flag: CV2Flag
|
||||
|
||||
// Paint by Example
|
||||
paintByExampleSteps: number
|
||||
paintByExampleGuidanceScale: number
|
||||
paintByExampleSeed: number
|
||||
paintByExampleSeedFixed: boolean
|
||||
paintByExampleMaskBlur: number
|
||||
paintByExampleMatchHistograms: boolean
|
||||
|
||||
// InstructPix2Pix
|
||||
p2pSteps: number
|
||||
p2pImageGuidanceScale: number
|
||||
p2pGuidanceScale: number
|
||||
|
||||
// ControlNet
|
||||
controlnetConditioningScale: number
|
||||
controlnetMethod: string
|
||||
}
|
||||
|
||||
const defaultHDSettings: ModelsHDSettings = {
|
||||
[AIModel.LAMA]: {
|
||||
hdStrategy: HDStrategy.CROP,
|
||||
hdStrategyResizeLimit: 2048,
|
||||
hdStrategyCropTrigerSize: 800,
|
||||
hdStrategyCropMargin: 196,
|
||||
enabled: true,
|
||||
},
|
||||
[AIModel.LDM]: {
|
||||
hdStrategy: HDStrategy.CROP,
|
||||
hdStrategyResizeLimit: 1080,
|
||||
hdStrategyCropTrigerSize: 1080,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: true,
|
||||
},
|
||||
[AIModel.ZITS]: {
|
||||
hdStrategy: HDStrategy.CROP,
|
||||
hdStrategyResizeLimit: 1024,
|
||||
hdStrategyCropTrigerSize: 1024,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: true,
|
||||
},
|
||||
[AIModel.MAT]: {
|
||||
hdStrategy: HDStrategy.CROP,
|
||||
hdStrategyResizeLimit: 1024,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: true,
|
||||
},
|
||||
[AIModel.FCF]: {
|
||||
hdStrategy: HDStrategy.CROP,
|
||||
hdStrategyResizeLimit: 512,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.SD15]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.ANYTHING4]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.REALISTIC_VISION_1_4]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.SD2]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.PAINT_BY_EXAMPLE]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.PIX2PIX]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
[AIModel.Mange]: {
|
||||
hdStrategy: HDStrategy.CROP,
|
||||
hdStrategyResizeLimit: 1280,
|
||||
hdStrategyCropTrigerSize: 1024,
|
||||
hdStrategyCropMargin: 196,
|
||||
enabled: true,
|
||||
},
|
||||
[AIModel.CV2]: {
|
||||
hdStrategy: HDStrategy.RESIZE,
|
||||
hdStrategyResizeLimit: 1080,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: true,
|
||||
},
|
||||
[AIModel.KANDINSKY22]: {
|
||||
hdStrategy: HDStrategy.ORIGINAL,
|
||||
hdStrategyResizeLimit: 768,
|
||||
hdStrategyCropTrigerSize: 512,
|
||||
hdStrategyCropMargin: 128,
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
export enum SDSampler {
|
||||
ddim = "ddim",
|
||||
pndm = "pndm",
|
||||
klms = "k_lms",
|
||||
kEuler = "k_euler",
|
||||
kEulerA = "k_euler_a",
|
||||
dpmPlusPlus = "dpm++",
|
||||
uni_pc = "uni_pc",
|
||||
}
|
||||
|
||||
export enum SDMode {
|
||||
text2img = "text2img",
|
||||
img2img = "img2img",
|
||||
inpainting = "inpainting",
|
||||
}
|
||||
|
||||
export const settingStateDefault: Settings = {
|
||||
show: false,
|
||||
showCroper: false,
|
||||
downloadMask: false,
|
||||
graduallyInpainting: true,
|
||||
runInpaintingManually: false,
|
||||
model: AIModel.LAMA,
|
||||
hdSettings: defaultHDSettings,
|
||||
|
||||
ldmSteps: 25,
|
||||
ldmSampler: LDMSampler.plms,
|
||||
|
||||
zitsWireframe: true,
|
||||
|
||||
// SD
|
||||
sdMaskBlur: 5,
|
||||
sdMode: SDMode.inpainting,
|
||||
sdStrength: 0.75,
|
||||
sdSteps: 50,
|
||||
sdGuidanceScale: 7.5,
|
||||
sdSampler: SDSampler.uni_pc,
|
||||
sdSeed: 42,
|
||||
sdSeedFixed: false,
|
||||
sdNumSamples: 1,
|
||||
sdMatchHistograms: false,
|
||||
sdScale: 100,
|
||||
|
||||
// CV2
|
||||
cv2Radius: 5,
|
||||
cv2Flag: CV2Flag.INPAINT_NS,
|
||||
|
||||
// Paint by Example
|
||||
paintByExampleSteps: 50,
|
||||
paintByExampleGuidanceScale: 7.5,
|
||||
paintByExampleSeed: 42,
|
||||
paintByExampleMaskBlur: 5,
|
||||
paintByExampleSeedFixed: false,
|
||||
paintByExampleMatchHistograms: false,
|
||||
|
||||
// InstructPix2Pix
|
||||
p2pSteps: 50,
|
||||
p2pImageGuidanceScale: 1.5,
|
||||
p2pGuidanceScale: 7.5,
|
||||
|
||||
// ControlNet
|
||||
controlnetConditioningScale: 0.4,
|
||||
controlnetMethod: ControlNetMethod.canny,
|
||||
}
|
||||
|
||||
const localStorageEffect =
|
||||
(key: string) =>
|
||||
({ setSelf, onSet }: any) => {
|
||||
const savedValue = localStorage.getItem(key)
|
||||
if (savedValue != null) {
|
||||
const storageSettings = JSON.parse(savedValue)
|
||||
storageSettings.show = false
|
||||
|
||||
const restored = _.merge(
|
||||
_.cloneDeep(settingStateDefault),
|
||||
storageSettings
|
||||
)
|
||||
setSelf(restored)
|
||||
}
|
||||
|
||||
onSet((newValue: Settings, val: string, isReset: boolean) =>
|
||||
isReset
|
||||
? localStorage.removeItem(key)
|
||||
: localStorage.setItem(key, JSON.stringify(newValue))
|
||||
)
|
||||
}
|
||||
|
||||
const ROOT_STATE_KEY = "settingsState4"
|
||||
// Each atom can reference an array of these atom effect functions which are called in priority order when the atom is initialized
|
||||
// https://recoiljs.org/docs/guides/atom-effects/#local-storage-persistence
|
||||
export const settingState = atom<Settings>({
|
||||
key: ROOT_STATE_KEY,
|
||||
default: settingStateDefault,
|
||||
effects: [localStorageEffect(ROOT_STATE_KEY)],
|
||||
})
|
||||
|
||||
export const seedState = selector({
|
||||
key: "seed",
|
||||
get: ({ get }) => {
|
||||
const settings = get(settingState)
|
||||
switch (settings.model) {
|
||||
case AIModel.PAINT_BY_EXAMPLE:
|
||||
return settings.paintByExampleSeedFixed
|
||||
? settings.paintByExampleSeed
|
||||
: -1
|
||||
default:
|
||||
return settings.sdSeedFixed ? settings.sdSeed : -1
|
||||
}
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const settings = get(settingState)
|
||||
switch (settings.model) {
|
||||
case AIModel.PAINT_BY_EXAMPLE:
|
||||
if (!settings.paintByExampleSeedFixed) {
|
||||
set(settingState, { ...settings, paintByExampleSeed: newValue })
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (!settings.sdSeedFixed) {
|
||||
set(settingState, { ...settings, sdSeed: newValue })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const hdSettingsState = selector({
|
||||
key: "hdSettings",
|
||||
get: ({ get }) => {
|
||||
const settings = get(settingState)
|
||||
return settings.hdSettings[settings.model]
|
||||
},
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const settings = get(settingState)
|
||||
const hdSettings = settings.hdSettings[settings.model]
|
||||
const newHDSettings = { ...hdSettings, ...newValue }
|
||||
|
||||
set(settingState, {
|
||||
...settings,
|
||||
hdSettings: { ...settings.hdSettings, [settings.model]: newHDSettings },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const isSDState = selector({
|
||||
key: "isSD",
|
||||
get: ({ get }) => {
|
||||
const settings = get(settingState)
|
||||
return (
|
||||
settings.model === AIModel.SD15 ||
|
||||
settings.model === AIModel.SD2 ||
|
||||
settings.model === AIModel.ANYTHING4 ||
|
||||
settings.model === AIModel.REALISTIC_VISION_1_4 ||
|
||||
settings.model === AIModel.KANDINSKY22
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const isPaintByExampleState = selector({
|
||||
key: "isPaintByExampleState",
|
||||
get: ({ get }) => {
|
||||
const settings = get(settingState)
|
||||
return settings.model === AIModel.PAINT_BY_EXAMPLE
|
||||
},
|
||||
})
|
||||
|
||||
export const isPix2PixState = selector({
|
||||
key: "isPix2PixState",
|
||||
get: ({ get }) => {
|
||||
const settings = get(settingState)
|
||||
return settings.model === AIModel.PIX2PIX
|
||||
},
|
||||
})
|
||||
|
||||
export const runManuallyState = selector({
|
||||
key: "runManuallyState",
|
||||
get: ({ get }) => {
|
||||
const settings = get(settingState)
|
||||
const isSD = get(isSDState)
|
||||
const isPaintByExample = get(isPaintByExampleState)
|
||||
const isPix2Pix = get(isPix2PixState)
|
||||
return (
|
||||
settings.runInpaintingManually || isSD || isPaintByExample || isPix2Pix
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const isDiffusionModelsState = selector({
|
||||
key: "isDiffusionModelsState",
|
||||
get: ({ get }) => {
|
||||
const isSD = get(isSDState)
|
||||
const isPaintByExample = get(isPaintByExampleState)
|
||||
const isPix2Pix = get(isPix2PixState)
|
||||
return isSD || isPaintByExample || isPix2Pix
|
||||
},
|
||||
})
|
||||
|
||||
export enum SortBy {
|
||||
NAME = "name",
|
||||
CTIME = "ctime",
|
||||
MTIME = "mtime",
|
||||
}
|
||||
|
||||
export enum SortOrder {
|
||||
DESCENDING = "desc",
|
||||
ASCENDING = "asc",
|
||||
}
|
||||
|
||||
interface FileManagerState {
|
||||
sortBy: SortBy
|
||||
sortOrder: SortOrder
|
||||
layout: "rows" | "masonry"
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const FILE_MANAGER_STATE_KEY = "fileManagerState"
|
||||
|
||||
export const fileManagerState = atom<FileManagerState>({
|
||||
key: FILE_MANAGER_STATE_KEY,
|
||||
default: {
|
||||
sortBy: SortBy.CTIME,
|
||||
sortOrder: SortOrder.DESCENDING,
|
||||
layout: "masonry",
|
||||
searchText: "",
|
||||
},
|
||||
effects: [localStorageEffect(FILE_MANAGER_STATE_KEY)],
|
||||
})
|
||||
|
||||
export const fileManagerSortBy = selector({
|
||||
key: "fileManagerSortBy",
|
||||
get: ({ get }) => get(fileManagerState).sortBy,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const val = get(fileManagerState)
|
||||
set(fileManagerState, { ...val, sortBy: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const fileManagerSortOrder = selector({
|
||||
key: "fileManagerSortOrder",
|
||||
get: ({ get }) => get(fileManagerState).sortOrder,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const val = get(fileManagerState)
|
||||
set(fileManagerState, { ...val, sortOrder: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const fileManagerLayout = selector({
|
||||
key: "fileManagerLayout",
|
||||
get: ({ get }) => get(fileManagerState).layout,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const val = get(fileManagerState)
|
||||
set(fileManagerState, { ...val, layout: newValue })
|
||||
},
|
||||
})
|
||||
|
||||
export const fileManagerSearchText = selector({
|
||||
key: "fileManagerSearchText",
|
||||
get: ({ get }) => get(fileManagerState).searchText,
|
||||
set: ({ get, set }, newValue: any) => {
|
||||
const val = get(fileManagerState)
|
||||
set(fileManagerState, { ...val, searchText: newValue })
|
||||
},
|
||||
})
|
9
web_app/src/lib/types.ts
Normal file
9
web_app/src/lib/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export enum PluginName {
|
||||
RemoveBG = "RemoveBG",
|
||||
AnimeSeg = "AnimeSeg",
|
||||
RealESRGAN = "RealESRGAN",
|
||||
GFPGAN = "GFPGAN",
|
||||
RestoreFormer = "RestoreFormer",
|
||||
InteractiveSeg = "InteractiveSeg",
|
||||
MakeGIF = "MakeGIF",
|
||||
}
|
133
web_app/src/lib/utils.ts
Normal file
133
web_app/src/lib/utils.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { SyntheticEvent } from "react"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function keepGUIAlive() {
|
||||
async function getRequest(url = "") {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
cache: "no-cache",
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const keepAliveServer = () => {
|
||||
const url = document.location
|
||||
const route = "/flaskwebgui-keep-server-alive"
|
||||
getRequest(url + route).then((data) => {
|
||||
return data
|
||||
})
|
||||
}
|
||||
|
||||
const intervalRequest = 3 * 1000
|
||||
keepAliveServer()
|
||||
setInterval(keepAliveServer, intervalRequest)
|
||||
}
|
||||
|
||||
export function dataURItoBlob(dataURI: string) {
|
||||
const mime = dataURI.split(",")[0].split(":")[1].split(";")[0]
|
||||
const binary = atob(dataURI.split(",")[1])
|
||||
const array = []
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
array.push(binary.charCodeAt(i))
|
||||
}
|
||||
return new Blob([new Uint8Array(array)], { type: mime })
|
||||
}
|
||||
|
||||
export function loadImage(image: HTMLImageElement, src: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const initSRC = image.src
|
||||
const img = image
|
||||
img.onload = resolve
|
||||
img.onerror = (err) => {
|
||||
img.src = initSRC
|
||||
reject(err)
|
||||
}
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
export function srcToFile(src: string, fileName: string, mimeType: string) {
|
||||
return fetch(src)
|
||||
.then(function (res) {
|
||||
return res.arrayBuffer()
|
||||
})
|
||||
.then(function (buf) {
|
||||
return new File([buf], fileName, { type: mimeType })
|
||||
})
|
||||
}
|
||||
|
||||
export async function askWritePermission() {
|
||||
try {
|
||||
// The clipboard-write permission is granted automatically to pages
|
||||
// when they are the active tab. So it's not required, but it's more safe.
|
||||
const { state } = await navigator.permissions.query({
|
||||
name: "clipboard-write" as PermissionName,
|
||||
})
|
||||
return state === "granted"
|
||||
} catch (error) {
|
||||
// Browser compatibility / Security error (ONLY HTTPS) ...
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, mime: string): Promise<any> {
|
||||
return new Promise((resolve, reject) =>
|
||||
canvas.toBlob(async (d) => {
|
||||
if (d) {
|
||||
resolve(d)
|
||||
} else {
|
||||
reject(new Error("Expected toBlob() to be defined"))
|
||||
}
|
||||
}, mime)
|
||||
)
|
||||
}
|
||||
|
||||
const setToClipboard = async (blob: any) => {
|
||||
const data = [new ClipboardItem({ [blob.type]: blob })]
|
||||
await navigator.clipboard.write(data)
|
||||
}
|
||||
|
||||
export function isRightClick(ev: SyntheticEvent) {
|
||||
const mouseEvent = ev.nativeEvent as MouseEvent
|
||||
return mouseEvent.button === 2
|
||||
}
|
||||
|
||||
export function isMidClick(ev: SyntheticEvent) {
|
||||
const mouseEvent = ev.nativeEvent as MouseEvent
|
||||
return mouseEvent.button === 1
|
||||
}
|
||||
|
||||
export async function copyCanvasImage(canvas: HTMLCanvasElement) {
|
||||
const blob = await canvasToBlob(canvas, "image/png")
|
||||
try {
|
||||
await setToClipboard(blob)
|
||||
} catch {
|
||||
console.log("Copy image failed!")
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadImage(uri: string, name: string) {
|
||||
const link = document.createElement("a")
|
||||
link.href = uri
|
||||
link.download = name
|
||||
|
||||
// this is necessary as link.click() does not work on the latest firefox
|
||||
link.dispatchEvent(
|
||||
new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
})
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
// For Firefox it is necessary to delay revoking the ObjectURL
|
||||
// window.URL.revokeObjectURL(base64)
|
||||
link.remove()
|
||||
}, 100)
|
||||
}
|
16
web_app/src/main.tsx
Normal file
16
web_app/src/main.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { RecoilRoot } from "recoil"
|
||||
import App from "./App.tsx"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "./components/theme-provider.tsx"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<RecoilRoot>
|
||||
<App />
|
||||
</RecoilRoot>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
1
web_app/src/vite-env.d.ts
vendored
Normal file
1
web_app/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
76
web_app/tailwind.config.js
Normal file
76
web_app/tailwind.config.js
Normal file
@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: 0 },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
32
web_app/tsconfig.json
Normal file
32
web_app/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
10
web_app/tsconfig.node.json
Normal file
10
web_app/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
12
web_app/vite.config.ts
Normal file
12
web_app/vite.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import path from "path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue
Block a user