new web init
This commit is contained in:
1704
web_app/src/components/Editor.tsx
Normal file
1704
web_app/src/components/Editor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
web_app/src/components/FileManager.tsx
Normal file
321
web_app/src/components/FileManager.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
FormEvent,
|
||||
} from "react"
|
||||
import _ from "lodash"
|
||||
import { useRecoilState } from "recoil"
|
||||
import PhotoAlbum from "react-photo-album"
|
||||
import {
|
||||
BarsArrowDownIcon,
|
||||
BarsArrowUpIcon,
|
||||
FolderIcon,
|
||||
} from "@heroicons/react/24/outline"
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ViewHorizontalIcon,
|
||||
ViewGridIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import { useDebounce, useToggle } from "react-use"
|
||||
import FlexSearch from "flexsearch/dist/flexsearch.bundle.js"
|
||||
import {
|
||||
fileManagerLayout,
|
||||
fileManagerSearchText,
|
||||
fileManagerSortBy,
|
||||
fileManagerSortOrder,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
} from "@/lib/store"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { API_ENDPOINT, getMedias } from "@/lib/api"
|
||||
import { IconButton } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select"
|
||||
import { ScrollArea } from "./ui/scroll-area"
|
||||
import { DialogTrigger } from "@radix-ui/react-dialog"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
interface Photo {
|
||||
src: string
|
||||
height: number
|
||||
width: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Filename {
|
||||
name: string
|
||||
height: number
|
||||
width: number
|
||||
ctime: number
|
||||
mtime: number
|
||||
}
|
||||
|
||||
const SORT_BY_NAME = "Name"
|
||||
const SORT_BY_CREATED_TIME = "Created time"
|
||||
const SORT_BY_MODIFIED_TIME = "Modified time"
|
||||
|
||||
const IMAGE_TAB = "image"
|
||||
const OUTPUT_TAB = "output"
|
||||
|
||||
const SortByMap = {
|
||||
[SortBy.NAME]: SORT_BY_NAME,
|
||||
[SortBy.CTIME]: SORT_BY_CREATED_TIME,
|
||||
[SortBy.MTIME]: SORT_BY_MODIFIED_TIME,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPhotoClick(tab: string, filename: string): void
|
||||
photoWidth: number
|
||||
}
|
||||
|
||||
export default function FileManager(props: Props) {
|
||||
const { onPhotoClick, photoWidth } = props
|
||||
const [open, toggleOpen] = useToggle(false)
|
||||
|
||||
useHotkeys("f", () => {
|
||||
toggleOpen()
|
||||
})
|
||||
|
||||
const { toast } = useToast()
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const [closeScrollTop, setCloseScrollTop] = useState(0)
|
||||
|
||||
const [sortBy, setSortBy] = useRecoilState<SortBy>(fileManagerSortBy)
|
||||
const [sortOrder, setSortOrder] = useRecoilState(fileManagerSortOrder)
|
||||
const [layout, setLayout] = useRecoilState(fileManagerLayout)
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useRecoilState(
|
||||
fileManagerSearchText
|
||||
)
|
||||
const ref = useRef(null)
|
||||
const [searchText, setSearchText] = useState(debouncedSearchText)
|
||||
const [tab, setTab] = useState(IMAGE_TAB)
|
||||
const [photos, setPhotos] = useState<Photo[]>([])
|
||||
|
||||
const [, cancel] = useDebounce(
|
||||
() => {
|
||||
setDebouncedSearchText(searchText)
|
||||
},
|
||||
300,
|
||||
[searchText]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCloseScrollTop(scrollTop)
|
||||
}
|
||||
}, [open, scrollTop])
|
||||
|
||||
const onRefChange = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
if (node !== null) {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
// TODO: without timeout, scrollTo not work, why?
|
||||
node.scrollTo({ top: closeScrollTop, left: 0 })
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, closeScrollTop]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const filenames = await getMedias(tab)
|
||||
let filteredFilenames = filenames
|
||||
if (debouncedSearchText) {
|
||||
const index = new FlexSearch.Index({
|
||||
tokenize: "forward",
|
||||
minlength: 1,
|
||||
})
|
||||
filenames.forEach((filename: Filename, id: number) =>
|
||||
index.add(id, filename.name)
|
||||
)
|
||||
const results: FlexSearch.IndexSearchResult =
|
||||
index.search(debouncedSearchText)
|
||||
filteredFilenames = results.map(
|
||||
(id: FlexSearch.Id) => filenames[id as number]
|
||||
)
|
||||
}
|
||||
|
||||
filteredFilenames = _.orderBy(filteredFilenames, sortBy, sortOrder)
|
||||
|
||||
const newPhotos = filteredFilenames.map((filename: Filename) => {
|
||||
const width = photoWidth
|
||||
const height = filename.height * (width / filename.width)
|
||||
const src = `${API_ENDPOINT}/media_thumbnail/${tab}/${filename.name}?width=${width}&height=${height}`
|
||||
return { src, height, width, name: filename.name }
|
||||
})
|
||||
setPhotos(newPhotos)
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Uh oh! Something went wrong.",
|
||||
description: e.message ? e.message : e.toString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [tab, debouncedSearchText, sortBy, sortOrder, photoWidth, open])
|
||||
|
||||
const onScroll = (event: SyntheticEvent) => {
|
||||
setScrollTop(event.currentTarget.scrollTop)
|
||||
}
|
||||
|
||||
const onClick = ({ index }: { index: number }) => {
|
||||
toggleOpen()
|
||||
onPhotoClick(tab, photos[index].name)
|
||||
}
|
||||
|
||||
const renderTitle = () => {
|
||||
return (
|
||||
<div className="flex justify-start items-center gap-[12px]">
|
||||
<div>{`Images (${photos.length})`}</div>
|
||||
<div className="flex">
|
||||
<IconButton
|
||||
tooltip="Rows layout"
|
||||
onClick={() => {
|
||||
setLayout("rows")
|
||||
}}
|
||||
>
|
||||
<ViewHorizontalIcon
|
||||
className={layout !== "rows" ? "opacity-50" : ""}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
tooltip="Grid layout"
|
||||
onClick={() => {
|
||||
setLayout("masonry")
|
||||
}}
|
||||
className={layout !== "masonry" ? "opacity-50" : ""}
|
||||
>
|
||||
<ViewGridIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={toggleOpen}>
|
||||
<DialogTrigger>
|
||||
<IconButton tooltip="File Manager">
|
||||
<FolderIcon />
|
||||
</IconButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="h-4/5 max-w-6xl">
|
||||
<DialogTitle>{renderTitle()}</DialogTitle>
|
||||
<div className="flex justify-between gap-8 items-center">
|
||||
<div className="flex relative justify-start items-center">
|
||||
<MagnifyingGlassIcon className="absolute left-[8px]" />
|
||||
<Input
|
||||
ref={ref}
|
||||
value={searchText}
|
||||
className="w-[250px] pl-[30px]"
|
||||
tabIndex={-1}
|
||||
onInput={(evt: FormEvent<HTMLInputElement>) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as HTMLInputElement
|
||||
setSearchText(target.value)
|
||||
}}
|
||||
placeholder="Search by file name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={tab} onValueChange={(val) => setTab(val)}>
|
||||
<TabsList aria-label="Manage your account">
|
||||
<TabsTrigger value={IMAGE_TAB}>Image Directory</TabsTrigger>
|
||||
<TabsTrigger value={OUTPUT_TAB}>Output Directory</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Select
|
||||
value={SortByMap[sortBy]}
|
||||
onValueChange={(val) => {
|
||||
switch (val) {
|
||||
case SORT_BY_NAME:
|
||||
setSortBy(SortBy.NAME)
|
||||
break
|
||||
case SORT_BY_CREATED_TIME:
|
||||
setSortBy(SortBy.CTIME)
|
||||
break
|
||||
case SORT_BY_MODIFIED_TIME:
|
||||
setSortBy(SortBy.MTIME)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(SortByMap).map((val) => {
|
||||
return (
|
||||
<SelectItem value={val} key={val}>
|
||||
{val}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sortOrder === SortOrder.DESCENDING ? (
|
||||
<IconButton
|
||||
tooltip="Descending Order"
|
||||
onClick={() => {
|
||||
setSortOrder(SortOrder.ASCENDING)
|
||||
}}
|
||||
>
|
||||
<BarsArrowDownIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
tooltip="Ascending Order"
|
||||
onClick={() => {
|
||||
setSortOrder(SortOrder.DESCENDING)
|
||||
}}
|
||||
>
|
||||
<BarsArrowUpIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className="w-full h-full rounded-md"
|
||||
onScroll={onScroll}
|
||||
ref={onRefChange}
|
||||
>
|
||||
<PhotoAlbum
|
||||
layout={layout}
|
||||
photos={photos}
|
||||
spacing={12}
|
||||
padding={0}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
71
web_app/src/components/FileSelect.tsx
Normal file
71
web_app/src/components/FileSelect.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from "react"
|
||||
import useResolution from "@/hooks/useResolution"
|
||||
|
||||
type FileSelectProps = {
|
||||
onSelection: (file: File) => void
|
||||
}
|
||||
|
||||
export default function FileSelect(props: FileSelectProps) {
|
||||
const { onSelection } = props
|
||||
|
||||
const [uploadElemId] = useState(`file-upload-${Math.random().toString()}`)
|
||||
|
||||
const resolution = useResolution()
|
||||
|
||||
function onFileSelected(file: File) {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
// Skip non-image files
|
||||
const isImage = file.type.match("image.*")
|
||||
if (!isImage) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Check if file is larger than 20mb
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
throw new Error("file too large")
|
||||
}
|
||||
onSelection(file)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line
|
||||
alert(`error: ${(e as any).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute flex w-screen h-screen justify-center items-center ">
|
||||
<label
|
||||
htmlFor={uploadElemId}
|
||||
className="grid cursor-pointer border-[2px] border-[dashed] rounded-lg min-w-[600px] hover:cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
<div
|
||||
className="grid p-16 w-full h-full"
|
||||
onDragOver={(ev) => {
|
||||
ev.stopPropagation()
|
||||
ev.preventDefault()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="hidden"
|
||||
id={uploadElemId}
|
||||
name={uploadElemId}
|
||||
type="file"
|
||||
onChange={(ev) => {
|
||||
const file = ev.currentTarget.files?.[0]
|
||||
if (file) {
|
||||
onFileSelected(file)
|
||||
}
|
||||
}}
|
||||
accept="image/png, image/jpeg"
|
||||
/>
|
||||
<p className="text-center">
|
||||
{resolution === "desktop"
|
||||
? "Click here or drag an image file"
|
||||
: "Tap here to load your picture"}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
web_app/src/components/Header.tsx
Normal file
173
web_app/src/components/Header.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { FolderIcon, PhotoIcon } from "@heroicons/react/24/outline"
|
||||
import { PlayIcon } from "@radix-ui/react-icons"
|
||||
import React, { useCallback, useState } from "react"
|
||||
import { useRecoilState, useRecoilValue } from "recoil"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import {
|
||||
enableFileManagerState,
|
||||
fileState,
|
||||
isInpaintingState,
|
||||
isPix2PixState,
|
||||
isSDState,
|
||||
maskState,
|
||||
runManuallyState,
|
||||
showFileManagerState,
|
||||
} from "@/lib/store"
|
||||
import { Button, IconButton, ImageUploadButton } from "@/components/ui/button"
|
||||
import Shortcuts from "@/components/Shortcuts"
|
||||
// import SettingIcon from "../Settings/SettingIcon"
|
||||
// import PromptInput from "./PromptInput"
|
||||
// import CoffeeIcon from '../CoffeeIcon/CoffeeIcon'
|
||||
import emitter, {
|
||||
DREAM_BUTTON_MOUSE_ENTER,
|
||||
DREAM_BUTTON_MOUSE_LEAVE,
|
||||
EVENT_CUSTOM_MASK,
|
||||
RERUN_LAST_MASK,
|
||||
} from "@/lib/event"
|
||||
import { useImage } from "@/hooks/useImage"
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"
|
||||
import PromptInput from "./PromptInput"
|
||||
import { RotateCw } from "lucide-react"
|
||||
import FileManager from "./FileManager"
|
||||
import { getMediaFile } from "@/lib/api"
|
||||
|
||||
const Header = () => {
|
||||
const isInpainting = useRecoilValue(isInpaintingState)
|
||||
const [file, setFile] = useRecoilState(fileState)
|
||||
const [mask, setMask] = useRecoilState(maskState)
|
||||
const [maskImage, maskImageLoaded] = useImage(mask)
|
||||
const isSD = useRecoilValue(isSDState)
|
||||
const isPix2Pix = useRecoilValue(isPix2PixState)
|
||||
const runManually = useRecoilValue(runManuallyState)
|
||||
const [openMaskPopover, setOpenMaskPopover] = useState(false)
|
||||
const enableFileManager = useRecoilValue(enableFileManagerState)
|
||||
|
||||
const handleRerunLastMask = useCallback(() => {
|
||||
emitter.emit(RERUN_LAST_MASK)
|
||||
}, [])
|
||||
|
||||
const onRerunMouseEnter = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_ENTER)
|
||||
}
|
||||
|
||||
const onRerunMouseLeave = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_LEAVE)
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
"r",
|
||||
() => {
|
||||
if (!isInpainting) {
|
||||
handleRerunLastMask()
|
||||
}
|
||||
},
|
||||
{},
|
||||
[isInpainting, handleRerunLastMask]
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="h-[60px] px-6 py-4 absolute top-[0] flex justify-between items-center w-full z-20 backdrop-filter backdrop-blur-md border-b">
|
||||
<div className="flex items-center">
|
||||
{enableFileManager ? (
|
||||
<FileManager
|
||||
photoWidth={512}
|
||||
onPhotoClick={async (tab: string, filename: string) => {
|
||||
const newFile = await getMediaFile(tab, filename)
|
||||
setFile(newFile)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<ImageUploadButton
|
||||
disabled={isInpainting}
|
||||
tooltip="Upload image"
|
||||
onFileUpload={(file) => {
|
||||
setFile(file)
|
||||
}}
|
||||
>
|
||||
<PhotoIcon />
|
||||
</ImageUploadButton>
|
||||
|
||||
<div
|
||||
style={{
|
||||
visibility: file ? "visible" : "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ImageUploadButton
|
||||
disabled={isInpainting}
|
||||
tooltip="Upload custom mask"
|
||||
onFileUpload={(file) => {
|
||||
setMask(file)
|
||||
console.info("Send custom mask")
|
||||
if (!runManually) {
|
||||
emitter.emit(EVENT_CUSTOM_MASK, { mask: file })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>M</div>
|
||||
</ImageUploadButton>
|
||||
|
||||
{mask ? (
|
||||
<Popover open={openMaskPopover}>
|
||||
<PopoverTrigger
|
||||
className="btn-primary side-panel-trigger"
|
||||
onMouseEnter={() => setOpenMaskPopover(true)}
|
||||
onMouseLeave={() => setOpenMaskPopover(false)}
|
||||
style={{
|
||||
visibility: mask ? "visible" : "hidden",
|
||||
outline: "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (mask) {
|
||||
emitter.emit(EVENT_CUSTOM_MASK, { mask })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconButton tooltip="Run custom mask">
|
||||
<PlayIcon />
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
{maskImageLoaded ? (
|
||||
<img src={maskImage.src} alt="Custom mask" />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
disabled={isInpainting}
|
||||
tooltip="Rerun last mask"
|
||||
onClick={handleRerunLastMask}
|
||||
onMouseEnter={onRerunMouseEnter}
|
||||
onMouseLeave={onRerunMouseLeave}
|
||||
>
|
||||
<RotateCw />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSD ? <PromptInput /> : <></>}
|
||||
|
||||
<div className="header-icons-wrapper">
|
||||
{/* <CoffeeIcon /> */}
|
||||
<div className="header-icons">
|
||||
<Shortcuts />
|
||||
{/* <SettingIcon /> */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
66
web_app/src/components/PromptInput.tsx
Normal file
66
web_app/src/components/PromptInput.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { FormEvent } from "react"
|
||||
import { useRecoilState, useRecoilValue } from "recoil"
|
||||
import emitter, {
|
||||
DREAM_BUTTON_MOUSE_ENTER,
|
||||
DREAM_BUTTON_MOUSE_LEAVE,
|
||||
EVENT_PROMPT,
|
||||
} from "@/lib/event"
|
||||
import { appState, isInpaintingState, propmtState } from "@/lib/store"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
|
||||
const PromptInput = () => {
|
||||
const app = useRecoilValue(appState)
|
||||
const [prompt, setPrompt] = useRecoilState(propmtState)
|
||||
const isInpainting = useRecoilValue(isInpaintingState)
|
||||
|
||||
const handleOnInput = (evt: FormEvent<HTMLInputElement>) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const target = evt.target as HTMLInputElement
|
||||
setPrompt(target.value)
|
||||
}
|
||||
|
||||
const handleRepaintClick = () => {
|
||||
if (prompt.length !== 0 && !app.isInpainting) {
|
||||
emitter.emit(EVENT_PROMPT)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isInpainting) {
|
||||
handleRepaintClick()
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseEnter = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_ENTER)
|
||||
}
|
||||
|
||||
const onMouseLeave = () => {
|
||||
emitter.emit(DREAM_BUTTON_MOUSE_LEAVE)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Input
|
||||
className="min-w-[600px]"
|
||||
value={prompt}
|
||||
onInput={handleOnInput}
|
||||
onKeyUp={onKeyUp}
|
||||
placeholder="I want to repaint of..."
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRepaintClick}
|
||||
disabled={prompt.length === 0 || app.isInpainting}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
Dream
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptInput
|
||||
97
web_app/src/components/Shortcuts.tsx
Normal file
97
web_app/src/components/Shortcuts.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Keyboard } from "lucide-react"
|
||||
import useHotKey from "@/hooks/useHotkey"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { useToggle } from "@uidotdev/usehooks"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./ui/dialog"
|
||||
|
||||
interface ShortcutProps {
|
||||
content: string
|
||||
keys: string[]
|
||||
}
|
||||
|
||||
function ShortCut(props: ShortcutProps) {
|
||||
const { content, keys } = props
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="shortcut-description">{content}</div>
|
||||
<div className="flex gap-[8px]">
|
||||
{keys.map((k) => (
|
||||
// TODO: 优化快捷键显示
|
||||
<div className="border px-2 py-1 rounded-lg" key={k}>
|
||||
{k}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isMac = function () {
|
||||
return /macintosh|mac os x/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
const isWindows = function () {
|
||||
return /windows|win32/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
const CmdOrCtrl = () => {
|
||||
return isMac() ? "Cmd" : "Ctrl"
|
||||
}
|
||||
|
||||
export function Shortcuts() {
|
||||
const [open, toggleOpen] = useToggle(false)
|
||||
|
||||
useHotKey("h", () => {
|
||||
toggleOpen()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={toggleOpen}>
|
||||
<DialogTrigger>
|
||||
<IconButton tooltip="Hotkeys">
|
||||
<Keyboard />
|
||||
</IconButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Hotkeys</DialogTitle>
|
||||
<DialogDescription className="flex gap-2 flex-col pt-4">
|
||||
<ShortCut content="Pan" keys={["Space + Drag"]} />
|
||||
<ShortCut content="Reset Zoom/Pan" keys={["Esc"]} />
|
||||
<ShortCut content="Decrease Brush Size" keys={["["]} />
|
||||
<ShortCut content="Increase Brush Size" keys={["]"]} />
|
||||
<ShortCut content="View Original Image" keys={["Hold Tab"]} />
|
||||
<ShortCut
|
||||
content="Multi-Stroke Drawing"
|
||||
keys={[`Hold ${CmdOrCtrl()}`]}
|
||||
/>
|
||||
<ShortCut content="Cancel Drawing" keys={["Esc"]} />
|
||||
|
||||
<ShortCut content="Rerun last mask" keys={["R"]} />
|
||||
<ShortCut content="Undo" keys={[CmdOrCtrl(), "Z"]} />
|
||||
<ShortCut content="Redo" keys={[CmdOrCtrl(), "Shift", "Z"]} />
|
||||
<ShortCut content="Copy Result" keys={[CmdOrCtrl(), "C"]} />
|
||||
<ShortCut content="Paste Image" keys={[CmdOrCtrl(), "V"]} />
|
||||
<ShortCut
|
||||
content="Trigger Manually Inpainting"
|
||||
keys={["Shift", "R"]}
|
||||
/>
|
||||
<ShortCut content="Toggle Hotkeys Dialog" keys={["H"]} />
|
||||
<ShortCut content="Toggle Settings Dialog" keys={["S"]} />
|
||||
<ShortCut content="Toggle File Manager" keys={["F"]} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Shortcuts
|
||||
114
web_app/src/components/Workspace.tsx
Normal file
114
web_app/src/components/Workspace.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect } from "react"
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil"
|
||||
import Editor from "./Editor"
|
||||
// import SettingModal from "./Settings/SettingsModal"
|
||||
// import Toast from "./shared/Toast"
|
||||
import {
|
||||
AIModel,
|
||||
fileState,
|
||||
isPaintByExampleState,
|
||||
isPix2PixState,
|
||||
isSDState,
|
||||
settingState,
|
||||
showFileManagerState,
|
||||
toastState,
|
||||
} from "@/lib/store"
|
||||
import {
|
||||
currentModel,
|
||||
getMediaFile,
|
||||
modelDownloaded,
|
||||
switchModel,
|
||||
} from "@/lib/api"
|
||||
// import SidePanel from "./SidePanel/SidePanel"
|
||||
// import PESidePanel from "./SidePanel/PESidePanel"
|
||||
// import FileManager from "./FileManager/FileManager"
|
||||
// import P2PSidePanel from "./SidePanel/P2PSidePanel"
|
||||
// import Plugins from "./Plugins/Plugins"
|
||||
// import Flex from "./shared/Layout"
|
||||
// import ImageSize from "./ImageSize/ImageSize"
|
||||
|
||||
const Workspace = () => {
|
||||
const setFile = useSetRecoilState(fileState)
|
||||
const [settings, setSettingState] = useRecoilState(settingState)
|
||||
const [toastVal, setToastState] = useRecoilState(toastState)
|
||||
const isSD = useRecoilValue(isSDState)
|
||||
const isPaintByExample = useRecoilValue(isPaintByExampleState)
|
||||
const isPix2Pix = useRecoilValue(isPix2PixState)
|
||||
|
||||
const onSettingClose = async () => {
|
||||
const curModel = await currentModel().then((res) => res.text())
|
||||
if (curModel === settings.model) {
|
||||
return
|
||||
}
|
||||
const downloaded = await modelDownloaded(settings.model).then((res) =>
|
||||
res.text()
|
||||
)
|
||||
|
||||
const { model } = settings
|
||||
|
||||
let loadingMessage = `Switching to ${model} model`
|
||||
let loadingDuration = 3000
|
||||
if (downloaded === "False") {
|
||||
loadingMessage = `Downloading ${model} model, this may take a while`
|
||||
loadingDuration = 9999999999
|
||||
}
|
||||
|
||||
setToastState({
|
||||
open: true,
|
||||
desc: loadingMessage,
|
||||
state: "loading",
|
||||
duration: loadingDuration,
|
||||
})
|
||||
|
||||
switchModel(model)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
setToastState({
|
||||
open: true,
|
||||
desc: `Switch to ${model} model success`,
|
||||
state: "success",
|
||||
duration: 3000,
|
||||
})
|
||||
} else {
|
||||
throw new Error("Server error")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setToastState({
|
||||
open: true,
|
||||
desc: `Switch to ${model} model failed`,
|
||||
state: "error",
|
||||
duration: 3000,
|
||||
})
|
||||
setSettingState((old) => {
|
||||
return { ...old, model: curModel as AIModel }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
currentModel()
|
||||
.then((res) => res.text())
|
||||
.then((model) => {
|
||||
setSettingState((old) => {
|
||||
return { ...old, model: model as AIModel }
|
||||
})
|
||||
})
|
||||
}, [setSettingState])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* {isSD ? <SidePanel /> : <></>}
|
||||
{isPaintByExample ? <PESidePanel /> : <></>}
|
||||
{isPix2Pix ? <P2PSidePanel /> : <></>}
|
||||
<Flex style={{ position: "absolute", top: 68, left: 24, gap: 12 }}>
|
||||
<Plugins />
|
||||
<ImageSize />
|
||||
</Flex>
|
||||
{/* <SettingModal onClose={onSettingClose} /> */}
|
||||
<Editor />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Workspace
|
||||
73
web_app/src/components/theme-provider.tsx
Normal file
73
web_app/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
55
web_app/src/components/ui/accordion.tsx
Normal file
55
web_app/src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
123
web_app/src/components/ui/button.tsx
Normal file
123
web_app/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "./input"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export interface IconButtonProps extends ButtonProps {
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
const IconButton = (props: IconButtonProps) => {
|
||||
const { tooltip, children, ...rest } = props
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" {...rest} asChild>
|
||||
<div className="p-[8px]">{children}</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface UploadButtonProps extends IconButtonProps {
|
||||
onFileUpload: (file: File) => void
|
||||
}
|
||||
|
||||
const ImageUploadButton = (props: UploadButtonProps) => {
|
||||
const { onFileUpload, children, ...rest } = props
|
||||
|
||||
const [uploadElemId] = React.useState(
|
||||
`file-upload-${Math.random().toString()}`
|
||||
)
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFile = ev.currentTarget.files?.[0]
|
||||
if (newFile) {
|
||||
onFileUpload(newFile)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={uploadElemId}>
|
||||
<IconButton {...rest}>{children}</IconButton>
|
||||
</label>
|
||||
<Input
|
||||
style={{ display: "none" }}
|
||||
id={uploadElemId}
|
||||
name={uploadElemId}
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
accept="image/png, image/jpeg"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, IconButton, ImageUploadButton, buttonVariants }
|
||||
120
web_app/src/components/ui/dialog.tsx
Normal file
120
web_app/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 flex flex-col w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
25
web_app/src/components/ui/input.tsx
Normal file
25
web_app/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
web_app/src/components/ui/label.tsx
Normal file
24
web_app/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
29
web_app/src/components/ui/popover.tsx
Normal file
29
web_app/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
46
web_app/src/components/ui/scroll-area.tsx
Normal file
46
web_app/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
162
web_app/src/components/ui/select.tsx
Normal file
162
web_app/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
26
web_app/src/components/ui/slider.tsx
Normal file
26
web_app/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
27
web_app/src/components/ui/switch.tsx
Normal file
27
web_app/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
53
web_app/src/components/ui/tabs.tsx
Normal file
53
web_app/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
24
web_app/src/components/ui/textarea.tsx
Normal file
24
web_app/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
127
web_app/src/components/ui/toast.tsx
Normal file
127
web_app/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
33
web_app/src/components/ui/toaster.tsx
Normal file
33
web_app/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
43
web_app/src/components/ui/toggle.tsx
Normal file
43
web_app/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-3",
|
||||
sm: "h-8 px-2",
|
||||
lg: "h-10 px-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
28
web_app/src/components/ui/tooltip.tsx
Normal file
28
web_app/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
192
web_app/src/components/ui/use-toast.ts
Normal file
192
web_app/src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
Reference in New Issue
Block a user