new web init

This commit is contained in:
Qing 2023-11-22 08:53:20 +08:00
parent a5c241ac02
commit 04c5dfece8
51 changed files with 11603 additions and 0 deletions

18
web_app/.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

65
web_app/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

173
web_app/src/App.tsx Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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>
)
}

View 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

View 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

View 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

View 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

View 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
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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>
)
}

View 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 }

View 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 }

View 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
View 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;
}
}

View 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
}

View 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

View 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 }

View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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" }]
}

View 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
View 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"),
},
},
})