├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── README.md ├── app ├── api.ts ├── assets │ ├── charlie-brown.svg │ ├── duck.png │ ├── hexagons.svg │ ├── image.png │ ├── wave01.svg │ └── wave02.svg ├── components │ ├── auto-resized-textarea.tsx │ ├── chat-bubble.tsx │ ├── custom-image.tsx │ ├── global-loading.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── action-bar.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── blockquote.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── clipboard.tsx │ │ ├── close-button.tsx │ │ ├── color-mode.tsx │ │ ├── data-list.tsx │ │ ├── dialog.tsx │ │ ├── empty-state.tsx │ │ ├── field.tsx │ │ ├── file-button.tsx │ │ ├── hover-card.tsx │ │ ├── input-group.tsx │ │ ├── link-button.tsx │ │ ├── menu.tsx │ │ ├── native-select.tsx │ │ ├── number-input.tsx │ │ ├── pagination.tsx │ │ ├── password-input.tsx │ │ ├── pin-input.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── prose.tsx │ │ ├── provider.tsx │ │ ├── radio-card.tsx │ │ ├── radio.tsx │ │ ├── rating.tsx │ │ ├── segmented-control.tsx │ │ ├── select.tsx │ │ ├── skeleton.tsx │ │ ├── status.tsx │ │ ├── stepper-input.tsx │ │ ├── steps.tsx │ │ ├── switch.tsx │ │ ├── tag.tsx │ │ ├── timeline.tsx │ │ ├── toaster.tsx │ │ ├── toggle-tip.tsx │ │ └── toggle.tsx ├── root.tsx ├── routes.ts ├── routes │ ├── account.tsx │ ├── chat.tsx │ ├── chat_index.tsx │ ├── chat_layout.tsx │ ├── home_layout.tsx │ ├── images.tsx │ ├── images_new.tsx │ ├── signin.tsx │ └── signup.tsx ├── types.ts └── utils │ ├── blurhash-to-dataurl.ts │ └── navigation.ts ├── biome.json ├── docker-compose.yml ├── docs ├── chat.png ├── home.png ├── images_light.png └── images_mobile_dark.png ├── nginx.conf ├── package.json ├── pnpm-lock.yaml ├── public └── favicon.ico ├── react-router.config.ts ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | npm-debug.log 4 | Dockerfile 5 | .dockerignore 6 | .gitignore 7 | README.md 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL=https://chat.indiething.app/api/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | .react-router 7 | fly.toml 8 | .vercel 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine 2 | 3 | # 删除默认的 nginx 静态文件 4 | RUN rm -rf /usr/share/nginx/html/* 5 | 6 | # 复制构建的文件到 nginx 目录 7 | COPY build/client /usr/share/nginx/html 8 | 9 | # 配置 nginx 10 | COPY nginx.conf /etc/nginx/conf.d/default.conf 11 | 12 | # 暴露 80 端口 13 | EXPOSE 80 14 | 15 | # 启动 nginx 16 | CMD ["nginx", "-g", "daemon off;"] 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # openchat-web 2 | 3 | ## 项目简介 4 | 5 | openchat-web是一个基于react router v7、chakra-ui、tanstack query、ky、zod的前端AI Chatbot项目。 6 | 7 | ## 配套后端地址: https://github.com/akazwz/openchat-go 8 | 9 | ### 功能特色 10 | 11 | - **自适应** 支持PC端、移动端。 12 | - **AI绘画** 支持AI绘画。 13 | - **AI聊天** 支持AI聊天。 14 | - **用户认证系统** 支持登录、注册。 15 | - **token刷新** 支持token过期自动刷新。 16 | 17 | ### 用到了什么 18 | 1. react router v7, prerender 19 | 2. chakra-ui 20 | 3. tanstack query 21 | 4. ky 22 | 5. zod 23 | 24 | ### 截图 25 | home 26 | images_mobile_dark 27 | images_light 28 | chat 29 | 30 | ### 部署说明 31 | 32 | 1. 克隆项目 33 | ```bash 34 | git clone https://github.com/akazwz/openchat-web.git 35 | cd openchat-web 36 | ``` 37 | 38 | 2. 安装依赖 39 | ```bash 40 | pnpm install 41 | ``` 42 | 43 | 3. 配置环境变量 44 | - 将 `.env.example` 文件复制并重命名为 `.env` 45 | - 在 `.env` 文件中设置后端API地址: 46 | ``` 47 | VITE_API_URL=你的后端API地址 48 | ``` 49 | 50 | 4. 运行项目 51 | ```bash 52 | pnpm dev 53 | ``` 54 | 55 | 5. 构建项目 56 | ```bash 57 | pnpm build 58 | ``` 59 | 60 | ### Docker 部署 61 | 62 | 项目提供了 Docker 和 Docker Compose 支持,可以轻松部署。默认打包为ssg 用 nginx 部署 63 | 64 | ### 使用 Docker Compose 部署(推荐) 65 | ```yaml 66 | # docker-compose.yml 已包含在项目中 67 | services: 68 | web: 69 | build: . 70 | ports: 71 | - "8080:80" 72 | restart: unless-stopped 73 | healthcheck: 74 | test: ["CMD", "curl", "-f", "http://localhost:80"] 75 | interval: 30s 76 | timeout: 10s 77 | retries: 3 78 | ``` 79 | 80 | 运行命令: 81 | ```bash 82 | # 构建并启动服务 83 | docker-compose up -d 84 | 85 | # 查看日志 86 | docker-compose logs -f 87 | ``` 88 | 89 | 访问 `http://localhost:8080` 即可看到应用。 90 | 91 | ### 手动 Docker 构建(可选) 92 | ```bash 93 | # 构建镜像 94 | docker build -t openchat-web . 95 | 96 | # 运行容器 97 | docker run -d -p 8080:80 --name openchat-web openchat-web 98 | ``` 99 | 100 | ### 注意事项 101 | 102 | - 应用默认运行在 8080 端口 103 | - 容器内使用 nginx 作为 web 服务器(80端口) 104 | - 已配置健康检查确保服务稳定性 105 | - 如需修改配置,可以通过挂载 nginx.conf 实现 106 | -------------------------------------------------------------------------------- /app/api.ts: -------------------------------------------------------------------------------- 1 | import ky from "ky"; 2 | import type { 3 | ConversationData, 4 | ImageResData, 5 | MessageData, 6 | SendMessageData, 7 | SigninRespData, 8 | UserData, 9 | } from "~/types"; 10 | import { toaster } from "~/components/ui/toaster"; 11 | import { getNavigate } from "~/utils/navigation"; 12 | 13 | const BASE_API_URL = import.meta.env.VITE_API_URL as string; 14 | 15 | let isRefreshing = false; 16 | let refreshSubscribers: ((token: string) => void)[] = []; 17 | 18 | function subscribeTokenRefresh(cb: (token: string) => void) { 19 | refreshSubscribers.push(cb); 20 | } 21 | function onRefreshed(token: string) { 22 | for (const subscriber of refreshSubscribers) { 23 | subscriber(token); 24 | } 25 | refreshSubscribers = []; 26 | } 27 | const refreshAccessToken = async (): Promise => { 28 | const refresh_token = localStorage.getItem("refresh_token"); 29 | const resp = await api 30 | .post("refresh_token", { 31 | json: { refresh_token }, 32 | }) 33 | .json(); 34 | localStorage.setItem("access_token", resp.access_token); 35 | localStorage.setItem("refresh_token", resp.refresh_token); 36 | onRefreshed(resp.access_token); 37 | return resp.access_token; 38 | }; 39 | 40 | const api = ky.create({ 41 | prefixUrl: BASE_API_URL, 42 | hooks: { 43 | beforeRequest: [ 44 | (request) => { 45 | const token = localStorage.getItem("access_token"); 46 | if (token) { 47 | request.headers.set("Authorization", `Bearer ${token}`); 48 | } 49 | }, 50 | ], 51 | afterResponse: [ 52 | async (request, options, response) => { 53 | if (response.status === 401) { 54 | if (!isRefreshing) { 55 | isRefreshing = true; 56 | try { 57 | const token = await refreshAccessToken(); 58 | request.headers.set("Authorization", `Bearer ${token}`); 59 | return api(request, options); 60 | } catch (error) { 61 | console.error("Failed to refresh token", error); 62 | toaster.error({ 63 | title: "登录过期", 64 | description: "请重新登录", 65 | }); 66 | const navigate = getNavigate(); 67 | navigate("/signin",{ 68 | viewTransition: true 69 | }); 70 | } finally { 71 | isRefreshing = false; 72 | } 73 | } else { 74 | return new Promise((resolve) => { 75 | subscribeTokenRefresh((token) => { 76 | request.headers.set("Authorization", `Bearer ${token}`); 77 | resolve(api(request, options)); 78 | }); 79 | }); 80 | } 81 | } 82 | }, 83 | ], 84 | }, 85 | }); 86 | 87 | const signin = (data: { username: string; password: string }) => 88 | api.post("signin", { json: data }).json(); 89 | 90 | const signup = (data: { username: string; password: string }) => 91 | api.post("signup", { json: data }).json(); 92 | 93 | const account = () => api.get("account").json(); 94 | 95 | const getConversations = () => 96 | api.get("conversations").json(); 97 | 98 | const createConversation = () => 99 | api.post("conversations").json(); 100 | 101 | const getConversation = (conversationId: string) => 102 | api.get(`conversations/${conversationId}`).json(); 103 | 104 | const deleteConversation = (conversationId: string) => 105 | api.delete(`conversations/${conversationId}`); 106 | 107 | const getMessages = (conversationId: string) => 108 | api.get(`conversations/${conversationId}/messages`).json(); 109 | 110 | const chat = (conversation_id: string, messages: SendMessageData[]) => 111 | api.post("chat", { 112 | json: { 113 | conversation_id, 114 | messages, 115 | }, 116 | }); 117 | 118 | const getImages = () => api.get("images").json(); 119 | 120 | const deleteImage = (id: string) => api.delete(`images/${id}`); 121 | 122 | const generateImages = (prompt: string) => 123 | api 124 | .post("cf/images", { json: { prompt }, timeout: 200000 }) 125 | .json(); 126 | 127 | const summarize = (conversationId: string, messages: SendMessageData[]) => 128 | api 129 | .post("summarize", { 130 | json: { 131 | conversation_id: conversationId, 132 | messages: messages, 133 | }, 134 | }) 135 | .json(); 136 | 137 | export default { 138 | signin, 139 | signup, 140 | account, 141 | getConversations, 142 | createConversation, 143 | getConversation, 144 | deleteConversation, 145 | getMessages, 146 | chat, 147 | getImages, 148 | deleteImage, 149 | generateImages, 150 | summarize, 151 | }; 152 | -------------------------------------------------------------------------------- /app/assets/charlie-brown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/duck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/app/assets/duck.png -------------------------------------------------------------------------------- /app/assets/hexagons.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/app/assets/image.png -------------------------------------------------------------------------------- /app/assets/wave01.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/wave02.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/auto-resized-textarea.tsx: -------------------------------------------------------------------------------- 1 | import AutoResize from "react-textarea-autosize"; 2 | import { chakra, useRecipe } from "@chakra-ui/react"; 3 | 4 | const StyledAutoResize = chakra(AutoResize); 5 | 6 | type AutoResizedTextareaProps = React.ComponentProps; 7 | 8 | export function AutoResizedTextarea(props: AutoResizedTextareaProps) { 9 | const recipe = useRecipe({ key: "textarea" }); 10 | const styles = recipe({ size: "sm" }); 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /app/components/chat-bubble.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, VStack, Text } from "@chakra-ui/react"; 2 | import { BotIcon } from "lucide-react"; 3 | import { memo } from "react"; 4 | import { formatRelative } from "date-fns"; 5 | import { zhCN } from "date-fns/locale"; 6 | import Markdown from "react-markdown"; 7 | import rehypeHighlight from "rehype-highlight"; 8 | import remarkGfm from "remark-gfm"; 9 | import { Avatar } from "~/components/ui/avatar"; 10 | import { Prose } from "~/components/ui/prose"; 11 | import type { MessageData } from "~/types"; 12 | 13 | interface ChatBubbleProps { 14 | message: MessageData; 15 | } 16 | 17 | export const ChatBubbleMemo = memo(({ message }: ChatBubbleProps) => { 18 | return ; 19 | }); 20 | 21 | export function ChatBubble({ message }: ChatBubbleProps) { 22 | return ( 23 | 30 | {message.role !== "user" && ( 31 | } 34 | /> 35 | )} 36 | 37 | 38 | {formatRelative(message.created_at, new Date(), { 39 | locale: zhCN, 40 | })} 41 | 42 | 53 | 57 | {message.content} 58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /app/components/custom-image.tsx: -------------------------------------------------------------------------------- 1 | import { type ImgHTMLAttributes, useEffect, useState } from "react"; 2 | import { blurHashToDataURL } from "~/utils/blurhash-to-dataurl"; 3 | 4 | export interface props extends ImgHTMLAttributes { 5 | blurhash: string; 6 | } 7 | 8 | export default function CustomImage(props: props) { 9 | const { blurhash } = props; 10 | const blurhashDataUrl = blurHashToDataURL(blurhash) as string; 11 | const [src, setSrc] = useState(blurhashDataUrl); 12 | 13 | useEffect(() => { 14 | const img = new Image(); 15 | img.src = props.src as string; 16 | img.onload = () => { 17 | setSrc(img.src); 18 | }; 19 | }, [props.src]); 20 | 21 | return {props.alt}; 22 | } 23 | -------------------------------------------------------------------------------- /app/components/global-loading.tsx: -------------------------------------------------------------------------------- 1 | import { ProgressBar, ProgressRoot } from "~/components/ui/progress"; 2 | 3 | export function GlobalLoading() { 4 | return ( 5 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion, HStack } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | import { LuChevronDown } from "react-icons/lu"; 4 | 5 | interface AccordionItemTriggerProps extends Accordion.ItemTriggerProps { 6 | indicatorPlacement?: "start" | "end"; 7 | } 8 | 9 | export const AccordionItemTrigger = forwardRef< 10 | HTMLButtonElement, 11 | AccordionItemTriggerProps 12 | >(function AccordionItemTrigger(props, ref) { 13 | const { children, indicatorPlacement = "end", ...rest } = props; 14 | return ( 15 | 16 | {indicatorPlacement === "start" && ( 17 | 18 | 19 | 20 | )} 21 | 22 | {children} 23 | 24 | {indicatorPlacement === "end" && ( 25 | 26 | 27 | 28 | )} 29 | 30 | ); 31 | }); 32 | 33 | interface AccordionItemContentProps extends Accordion.ItemContentProps {} 34 | 35 | export const AccordionItemContent = forwardRef< 36 | HTMLDivElement, 37 | AccordionItemContentProps 38 | >(function AccordionItemContent(props, ref) { 39 | return ( 40 | 41 | 42 | 43 | ); 44 | }); 45 | 46 | export const AccordionRoot = Accordion.Root; 47 | export const AccordionItem = Accordion.Item; 48 | -------------------------------------------------------------------------------- /app/components/ui/action-bar.tsx: -------------------------------------------------------------------------------- 1 | import { ActionBar, Portal } from "@chakra-ui/react"; 2 | import { CloseButton } from "./close-button"; 3 | import { forwardRef } from "react"; 4 | 5 | interface ActionBarContentProps extends ActionBar.ContentProps { 6 | portalled?: boolean; 7 | portalRef?: React.RefObject; 8 | } 9 | 10 | export const ActionBarContent = forwardRef< 11 | HTMLDivElement, 12 | ActionBarContentProps 13 | >(function ActionBarContent(props, ref) { 14 | const { children, portalled = true, portalRef, ...rest } = props; 15 | 16 | return ( 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | 24 | ); 25 | }); 26 | 27 | export const ActionBarCloseTrigger = forwardRef< 28 | HTMLButtonElement, 29 | ActionBar.CloseTriggerProps 30 | >(function ActionBarCloseTrigger(props, ref) { 31 | return ( 32 | 33 | 34 | 35 | ); 36 | }); 37 | 38 | export const ActionBarRoot = ActionBar.Root; 39 | export const ActionBarSelectionTrigger = ActionBar.SelectionTrigger; 40 | export const ActionBarSeparator = ActionBar.Separator; 41 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import { Alert as ChakraAlert } from "@chakra-ui/react"; 2 | import { CloseButton } from "./close-button"; 3 | import { forwardRef } from "react"; 4 | 5 | export interface AlertProps extends Omit { 6 | startElement?: React.ReactNode; 7 | endElement?: React.ReactNode; 8 | title?: React.ReactNode; 9 | icon?: React.ReactElement; 10 | closable?: boolean; 11 | onClose?: () => void; 12 | } 13 | 14 | export const Alert = forwardRef( 15 | function Alert(props, ref) { 16 | const { 17 | title, 18 | children, 19 | icon, 20 | closable, 21 | onClose, 22 | startElement, 23 | endElement, 24 | ...rest 25 | } = props; 26 | return ( 27 | 28 | {startElement || {icon}} 29 | {children ? ( 30 | 31 | {title} 32 | {children} 33 | 34 | ) : ( 35 | {title} 36 | )} 37 | {endElement} 38 | {closable && ( 39 | 47 | )} 48 | 49 | ); 50 | }, 51 | ); 52 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { GroupProps, SlotRecipeProps } from "@chakra-ui/react"; 4 | import { Avatar as ChakraAvatar, Group } from "@chakra-ui/react"; 5 | import { forwardRef } from "react"; 6 | 7 | type ImageProps = React.ImgHTMLAttributes; 8 | 9 | export interface AvatarProps extends ChakraAvatar.RootProps { 10 | name?: string; 11 | src?: string; 12 | srcSet?: string; 13 | loading?: ImageProps["loading"]; 14 | icon?: React.ReactElement; 15 | fallback?: React.ReactNode; 16 | } 17 | 18 | export const Avatar = forwardRef( 19 | function Avatar(props, ref) { 20 | const { name, src, srcSet, loading, icon, fallback, children, ...rest } = 21 | props; 22 | return ( 23 | 24 | 25 | {fallback} 26 | 27 | 28 | {children} 29 | 30 | ); 31 | }, 32 | ); 33 | 34 | interface AvatarFallbackProps extends ChakraAvatar.FallbackProps { 35 | name?: string; 36 | icon?: React.ReactElement; 37 | } 38 | 39 | const AvatarFallback = forwardRef( 40 | function AvatarFallback(props, ref) { 41 | const { name, icon, children, ...rest } = props; 42 | return ( 43 | 44 | {children} 45 | {name != null && children == null && <>{getInitials(name)}} 46 | {name == null && children == null && ( 47 | {icon} 48 | )} 49 | 50 | ); 51 | }, 52 | ); 53 | 54 | function getInitials(name: string) { 55 | const names = name.trim().split(" "); 56 | const firstName = names[0] != null ? names[0] : ""; 57 | const lastName = names.length > 1 ? names[names.length - 1] : ""; 58 | return firstName && lastName 59 | ? `${firstName.charAt(0)}${lastName.charAt(0)}` 60 | : firstName.charAt(0); 61 | } 62 | 63 | interface AvatarGroupProps extends GroupProps, SlotRecipeProps<"avatar"> {} 64 | 65 | export const AvatarGroup = forwardRef( 66 | function AvatarGroup(props, ref) { 67 | const { size, variant, borderless, ...rest } = props; 68 | return ( 69 | 70 | 71 | 72 | ); 73 | }, 74 | ); 75 | -------------------------------------------------------------------------------- /app/components/ui/blockquote.tsx: -------------------------------------------------------------------------------- 1 | import { Blockquote as ChakraBlockquote } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface BlockquoteProps extends ChakraBlockquote.RootProps { 5 | cite?: React.ReactNode; 6 | citeUrl?: string; 7 | icon?: React.ReactNode; 8 | showDash?: boolean; 9 | } 10 | 11 | export const Blockquote = forwardRef( 12 | function Blockquote(props, ref) { 13 | const { children, cite, citeUrl, showDash, icon, ...rest } = props; 14 | 15 | return ( 16 | 17 | {icon} 18 | 19 | {children} 20 | 21 | {cite && ( 22 | 23 | {showDash ? <>— : null} {cite} 24 | 25 | )} 26 | 27 | ); 28 | }, 29 | ); 30 | 31 | export const BlockquoteIcon = ChakraBlockquote.Icon; 32 | -------------------------------------------------------------------------------- /app/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumb, type SystemStyleObject } from "@chakra-ui/react"; 2 | import { Children, Fragment, forwardRef, isValidElement } from "react"; 3 | 4 | export interface BreadcrumbRootProps extends Breadcrumb.RootProps { 5 | separator?: React.ReactNode; 6 | separatorGap?: SystemStyleObject["gap"]; 7 | } 8 | 9 | export const BreadcrumbRoot = forwardRef( 10 | function BreadcrumbRoot(props, ref) { 11 | const { separator, separatorGap, children, ...rest } = props; 12 | const validChildren = Children.toArray(children).filter(isValidElement); 13 | return ( 14 | 15 | 16 | {validChildren.map((child, index) => { 17 | const last = index === validChildren.length - 1; 18 | return ( 19 | 20 | {child} 21 | {!last && ( 22 | {separator} 23 | )} 24 | 25 | ); 26 | })} 27 | 28 | 29 | ); 30 | }, 31 | ); 32 | 33 | export const BreadcrumbLink = Breadcrumb.Link; 34 | export const BreadcrumbCurrentLink = Breadcrumb.CurrentLink; 35 | export const BreadcrumbEllipsis = Breadcrumb.Ellipsis; 36 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps as ChakraButtonProps } from "@chakra-ui/react"; 2 | import { 3 | AbsoluteCenter, 4 | Button as ChakraButton, 5 | Span, 6 | Spinner, 7 | } from "@chakra-ui/react"; 8 | import { forwardRef } from "react"; 9 | 10 | interface ButtonLoadingProps { 11 | loading?: boolean; 12 | loadingText?: React.ReactNode; 13 | } 14 | 15 | export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {} 16 | 17 | export const Button = forwardRef( 18 | function Button(props, ref) { 19 | const { loading, disabled, loadingText, children, ...rest } = props; 20 | return ( 21 | 22 | {loading && !loadingText ? ( 23 | <> 24 | 25 | 26 | 27 | {children} 28 | 29 | ) : loading && loadingText ? ( 30 | <> 31 | 32 | {loadingText} 33 | 34 | ) : ( 35 | children 36 | )} 37 | 38 | ); 39 | }, 40 | ); 41 | -------------------------------------------------------------------------------- /app/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import { Checkbox as ChakraCheckbox } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface CheckboxProps extends ChakraCheckbox.RootProps { 5 | icon?: React.ReactNode; 6 | inputProps?: React.InputHTMLAttributes; 7 | rootRef?: React.Ref; 8 | } 9 | 10 | export const Checkbox = forwardRef( 11 | function Checkbox(props, ref) { 12 | const { icon, children, inputProps, rootRef, ...rest } = props; 13 | return ( 14 | 15 | 16 | 17 | {icon || } 18 | 19 | {children != null && ( 20 | {children} 21 | )} 22 | 23 | ); 24 | }, 25 | ); 26 | -------------------------------------------------------------------------------- /app/components/ui/clipboard.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps, InputProps } from "@chakra-ui/react"; 2 | import { 3 | Button, 4 | Clipboard as ChakraClipboard, 5 | IconButton, 6 | Input, 7 | } from "@chakra-ui/react"; 8 | import { forwardRef } from "react"; 9 | import { LuCheck, LuClipboard, LuLink } from "react-icons/lu"; 10 | 11 | const ClipboardIcon = forwardRef< 12 | HTMLDivElement, 13 | ChakraClipboard.IndicatorProps 14 | >(function ClipboardIcon(props, ref) { 15 | return ( 16 | } {...props} ref={ref}> 17 | 18 | 19 | ); 20 | }); 21 | 22 | const ClipboardCopyText = forwardRef< 23 | HTMLDivElement, 24 | ChakraClipboard.IndicatorProps 25 | >(function ClipboardCopyText(props, ref) { 26 | return ( 27 | 28 | Copy 29 | 30 | ); 31 | }); 32 | 33 | export const ClipboardLabel = forwardRef< 34 | HTMLLabelElement, 35 | ChakraClipboard.LabelProps 36 | >(function ClipboardLabel(props, ref) { 37 | return ( 38 | 46 | ); 47 | }); 48 | 49 | export const ClipboardButton = forwardRef( 50 | function ClipboardButton(props, ref) { 51 | return ( 52 | 53 | 57 | 58 | ); 59 | }, 60 | ); 61 | 62 | export const ClipboardLink = forwardRef( 63 | function ClipboardLink(props, ref) { 64 | return ( 65 | 66 | 79 | 80 | ); 81 | }, 82 | ); 83 | 84 | export const ClipboardIconButton = forwardRef( 85 | function ClipboardIconButton(props, ref) { 86 | return ( 87 | 88 | 89 | 90 | 91 | 92 | 93 | ); 94 | }, 95 | ); 96 | 97 | export const ClipboardInput = forwardRef( 98 | function ClipboardInputElement(props, ref) { 99 | return ( 100 | 101 | 102 | 103 | ); 104 | }, 105 | ); 106 | 107 | export const ClipboardRoot = ChakraClipboard.Root; 108 | -------------------------------------------------------------------------------- /app/components/ui/close-button.tsx: -------------------------------------------------------------------------------- 1 | import type { ButtonProps as ChakraCloseButtonProps } from "@chakra-ui/react"; 2 | import { IconButton as ChakraIconButton } from "@chakra-ui/react"; 3 | import { forwardRef } from "react"; 4 | import { LuX } from "react-icons/lu"; 5 | 6 | export interface CloseButtonProps extends ChakraCloseButtonProps {} 7 | 8 | export const CloseButton = forwardRef( 9 | function CloseButton(props, ref) { 10 | return ( 11 | 12 | {props.children ?? } 13 | 14 | ); 15 | }, 16 | ); 17 | -------------------------------------------------------------------------------- /app/components/ui/color-mode.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { IconButtonProps } from "@chakra-ui/react"; 4 | import { ClientOnly, IconButton, Skeleton } from "@chakra-ui/react"; 5 | import { ThemeProvider, useTheme } from "next-themes"; 6 | import type { ThemeProviderProps } from "next-themes"; 7 | import { forwardRef } from "react"; 8 | import { LuMoon, LuSun } from "react-icons/lu"; 9 | 10 | export interface ColorModeProviderProps extends ThemeProviderProps {} 11 | 12 | export function ColorModeProvider(props: ColorModeProviderProps) { 13 | return ( 14 | 15 | ); 16 | } 17 | 18 | export function useColorMode() { 19 | const { resolvedTheme, setTheme } = useTheme(); 20 | const toggleColorMode = () => { 21 | setTheme(resolvedTheme === "light" ? "dark" : "light"); 22 | }; 23 | return { 24 | colorMode: resolvedTheme, 25 | setColorMode: setTheme, 26 | toggleColorMode, 27 | }; 28 | } 29 | 30 | export function useColorModeValue(light: T, dark: T) { 31 | const { colorMode } = useColorMode(); 32 | return colorMode === "light" ? light : dark; 33 | } 34 | 35 | export function ColorModeIcon() { 36 | const { colorMode } = useColorMode(); 37 | return colorMode === "light" ? : ; 38 | } 39 | 40 | interface ColorModeButtonProps extends Omit {} 41 | 42 | export const ColorModeButton = forwardRef< 43 | HTMLButtonElement, 44 | ColorModeButtonProps 45 | >(function ColorModeButton(props, ref) { 46 | const { toggleColorMode } = useColorMode(); 47 | return ( 48 | }> 49 | 63 | 64 | 65 | 66 | ); 67 | }); 68 | -------------------------------------------------------------------------------- /app/components/ui/data-list.tsx: -------------------------------------------------------------------------------- 1 | import { DataList as ChakraDataList, IconButton } from "@chakra-ui/react"; 2 | import { ToggleTip } from "./toggle-tip"; 3 | import { forwardRef } from "react"; 4 | import { HiOutlineInformationCircle } from "react-icons/hi2"; 5 | 6 | export const DataListRoot = ChakraDataList.Root; 7 | 8 | interface ItemProps extends ChakraDataList.ItemProps { 9 | label: React.ReactNode; 10 | value: React.ReactNode; 11 | info?: React.ReactNode; 12 | grow?: boolean; 13 | } 14 | 15 | export const DataListItem = forwardRef( 16 | function DataListItem(props, ref) { 17 | const { label, info, value, children, grow, ...rest } = props; 18 | return ( 19 | 20 | 21 | {label} 22 | {info && ( 23 | 24 | 25 | 26 | 27 | 28 | )} 29 | 30 | 31 | {value} 32 | 33 | {children} 34 | 35 | ); 36 | }, 37 | ); 38 | -------------------------------------------------------------------------------- /app/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Dialog as ChakraDialog, Portal } from "@chakra-ui/react"; 2 | import { CloseButton } from "./close-button"; 3 | import { forwardRef } from "react"; 4 | 5 | interface DialogContentProps extends ChakraDialog.ContentProps { 6 | portalled?: boolean; 7 | portalRef?: React.RefObject; 8 | backdrop?: boolean; 9 | } 10 | 11 | export const DialogContent = forwardRef( 12 | function DialogContent(props, ref) { 13 | const { 14 | children, 15 | portalled = true, 16 | portalRef, 17 | backdrop = true, 18 | ...rest 19 | } = props; 20 | 21 | return ( 22 | 23 | {backdrop && } 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | ); 31 | }, 32 | ); 33 | 34 | export const DialogCloseTrigger = forwardRef< 35 | HTMLButtonElement, 36 | ChakraDialog.CloseTriggerProps 37 | >(function DialogCloseTrigger(props, ref) { 38 | return ( 39 | 46 | 47 | {props.children} 48 | 49 | 50 | ); 51 | }); 52 | 53 | export const DialogRoot = ChakraDialog.Root; 54 | export const DialogFooter = ChakraDialog.Footer; 55 | export const DialogHeader = ChakraDialog.Header; 56 | export const DialogBody = ChakraDialog.Body; 57 | export const DialogBackdrop = ChakraDialog.Backdrop; 58 | export const DialogTitle = ChakraDialog.Title; 59 | export const DialogDescription = ChakraDialog.Description; 60 | export const DialogTrigger = ChakraDialog.Trigger; 61 | export const DialogActionTrigger = ChakraDialog.ActionTrigger; 62 | -------------------------------------------------------------------------------- /app/components/ui/empty-state.tsx: -------------------------------------------------------------------------------- 1 | import { EmptyState as ChakraEmptyState, VStack } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface EmptyStateProps extends ChakraEmptyState.RootProps { 5 | title: string; 6 | description?: string; 7 | icon?: React.ReactNode; 8 | } 9 | 10 | export const EmptyState = forwardRef( 11 | function EmptyState(props, ref) { 12 | const { title, description, icon, children, ...rest } = props; 13 | return ( 14 | 15 | 16 | {icon && ( 17 | {icon} 18 | )} 19 | {description ? ( 20 | 21 | {title} 22 | 23 | {description} 24 | 25 | 26 | ) : ( 27 | {title} 28 | )} 29 | {children} 30 | 31 | 32 | ); 33 | }, 34 | ); 35 | -------------------------------------------------------------------------------- /app/components/ui/field.tsx: -------------------------------------------------------------------------------- 1 | import { Field as ChakraField } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface FieldProps extends Omit { 5 | label?: React.ReactNode; 6 | helperText?: React.ReactNode; 7 | errorText?: React.ReactNode; 8 | optionalText?: React.ReactNode; 9 | } 10 | 11 | export const Field = forwardRef( 12 | function Field(props, ref) { 13 | const { label, children, helperText, errorText, optionalText, ...rest } = 14 | props; 15 | return ( 16 | 17 | {label && ( 18 | 19 | {label} 20 | 21 | 22 | )} 23 | {children} 24 | {helperText && ( 25 | {helperText} 26 | )} 27 | {errorText && ( 28 | {errorText} 29 | )} 30 | 31 | ); 32 | }, 33 | ); 34 | -------------------------------------------------------------------------------- /app/components/ui/file-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ButtonProps, RecipeProps } from "@chakra-ui/react"; 4 | import { 5 | Button, 6 | FileUpload as ChakraFileUpload, 7 | Icon, 8 | IconButton, 9 | Span, 10 | Text, 11 | useFileUploadContext, 12 | useRecipe, 13 | } from "@chakra-ui/react"; 14 | import { forwardRef } from "react"; 15 | import { LuFile, LuUpload, LuX } from "react-icons/lu"; 16 | 17 | export interface FileUploadRootProps extends ChakraFileUpload.RootProps { 18 | inputProps?: React.InputHTMLAttributes; 19 | } 20 | 21 | export const FileUploadRoot = forwardRef( 22 | function FileUploadRoot(props, ref) { 23 | const { children, inputProps, ...rest } = props; 24 | return ( 25 | 26 | 27 | {children} 28 | 29 | ); 30 | }, 31 | ); 32 | 33 | export interface FileUploadDropzoneProps 34 | extends ChakraFileUpload.DropzoneProps { 35 | label: React.ReactNode; 36 | description?: React.ReactNode; 37 | } 38 | 39 | export const FileUploadDropzone = forwardRef< 40 | HTMLInputElement, 41 | FileUploadDropzoneProps 42 | >(function FileUploadDropzone(props, ref) { 43 | const { children, label, description, ...rest } = props; 44 | return ( 45 | 46 | 47 | 48 | 49 | 50 |
{label}
51 | {description && {description}} 52 |
53 | {children} 54 |
55 | ); 56 | }); 57 | 58 | interface VisibilityProps { 59 | showSize?: boolean; 60 | clearable?: boolean; 61 | } 62 | 63 | interface FileUploadItemProps extends VisibilityProps { 64 | file: File; 65 | } 66 | 67 | const FileUploadItem = (props: FileUploadItemProps) => { 68 | const { file, showSize, clearable } = props; 69 | return ( 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {showSize ? ( 78 | 79 | 80 | 81 | 82 | ) : ( 83 | 84 | )} 85 | 86 | {clearable && ( 87 | 88 | 89 | 90 | 91 | 92 | )} 93 | 94 | ); 95 | }; 96 | 97 | interface FileUploadListProps 98 | extends VisibilityProps, 99 | ChakraFileUpload.ItemGroupProps { 100 | files?: File[]; 101 | } 102 | 103 | export const FileUploadList = forwardRef( 104 | function FileUploadList(props, ref) { 105 | const { showSize, clearable, files, ...rest } = props; 106 | 107 | const fileUpload = useFileUploadContext(); 108 | const acceptedFiles = files ?? fileUpload.acceptedFiles; 109 | 110 | if (acceptedFiles.length === 0) return null; 111 | 112 | return ( 113 | 114 | {acceptedFiles.map((file) => ( 115 | 121 | ))} 122 | 123 | ); 124 | }, 125 | ); 126 | 127 | type Assign = Omit & U; 128 | 129 | interface FileInputProps extends Assign> { 130 | placeholder?: React.ReactNode; 131 | } 132 | 133 | export const FileInput = forwardRef( 134 | function FileInput(props, ref) { 135 | const inputRecipe = useRecipe({ key: "input" }); 136 | const [recipeProps, restProps] = inputRecipe.splitVariantProps(props); 137 | const { placeholder = "Select file(s)", ...rest } = restProps; 138 | return ( 139 | 140 | 159 | 160 | ); 161 | }, 162 | ); 163 | 164 | export const FileUploadLabel = ChakraFileUpload.Label; 165 | export const FileUploadClearTrigger = ChakraFileUpload.ClearTrigger; 166 | export const FileUploadTrigger = ChakraFileUpload.Trigger; 167 | -------------------------------------------------------------------------------- /app/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import { HoverCard, Portal } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | interface HoverCardContentProps extends HoverCard.ContentProps { 5 | portalled?: boolean; 6 | portalRef?: React.RefObject; 7 | } 8 | 9 | export const HoverCardContent = forwardRef< 10 | HTMLDivElement, 11 | HoverCardContentProps 12 | >(function HoverCardContent(props, ref) { 13 | const { portalled = true, portalRef, ...rest } = props; 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }); 23 | 24 | export const HoverCardArrow = forwardRef( 25 | function HoverCardArrow(props, ref) { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | }, 32 | ); 33 | 34 | export const HoverCardRoot = HoverCard.Root; 35 | export const HoverCardTrigger = HoverCard.Trigger; 36 | -------------------------------------------------------------------------------- /app/components/ui/input-group.tsx: -------------------------------------------------------------------------------- 1 | import type { BoxProps, InputElementProps } from "@chakra-ui/react"; 2 | import { Group, InputElement } from "@chakra-ui/react"; 3 | import { cloneElement, forwardRef } from "react"; 4 | 5 | export interface InputGroupProps extends BoxProps { 6 | startElementProps?: InputElementProps; 7 | endElementProps?: InputElementProps; 8 | startElement?: React.ReactNode; 9 | endElement?: React.ReactNode; 10 | children: React.ReactElement; 11 | } 12 | 13 | export const InputGroup = forwardRef( 14 | function InputGroup(props, ref) { 15 | const { 16 | startElement, 17 | startElementProps, 18 | endElement, 19 | endElementProps, 20 | children, 21 | ...rest 22 | } = props; 23 | 24 | return ( 25 | 26 | {startElement && ( 27 | 28 | {startElement} 29 | 30 | )} 31 | {cloneElement(children, { 32 | ...(startElement && { ps: "calc(var(--input-height) - 6px)" }), 33 | ...(endElement && { pe: "calc(var(--input-height) - 6px)" }), 34 | ...children.props, 35 | })} 36 | {endElement && ( 37 | 38 | {endElement} 39 | 40 | )} 41 | 42 | ); 43 | }, 44 | ); 45 | -------------------------------------------------------------------------------- /app/components/ui/link-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { HTMLChakraProps, RecipeProps } from "@chakra-ui/react"; 4 | import { createRecipeContext } from "@chakra-ui/react"; 5 | 6 | export interface LinkButtonProps 7 | extends HTMLChakraProps<"a", RecipeProps<"button">> {} 8 | 9 | const { withContext } = createRecipeContext({ key: "button" }); 10 | 11 | // Replace "a" with your framework's link component 12 | export const LinkButton = withContext("a"); 13 | -------------------------------------------------------------------------------- /app/components/ui/menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AbsoluteCenter, Menu as ChakraMenu, Portal } from "@chakra-ui/react"; 4 | import { forwardRef } from "react"; 5 | import { LuCheck, LuChevronRight } from "react-icons/lu"; 6 | 7 | interface MenuContentProps extends ChakraMenu.ContentProps { 8 | portalled?: boolean; 9 | portalRef?: React.RefObject; 10 | } 11 | 12 | export const MenuContent = forwardRef( 13 | function MenuContent(props, ref) { 14 | const { portalled = true, portalRef, ...rest } = props; 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }, 23 | ); 24 | 25 | export const MenuArrow = forwardRef( 26 | function MenuArrow(props, ref) { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | }, 33 | ); 34 | 35 | export const MenuCheckboxItem = forwardRef< 36 | HTMLDivElement, 37 | ChakraMenu.CheckboxItemProps 38 | >(function MenuCheckboxItem(props, ref) { 39 | return ( 40 | 41 | 44 | {props.children} 45 | 46 | ); 47 | }); 48 | 49 | export const MenuRadioItem = forwardRef< 50 | HTMLDivElement, 51 | ChakraMenu.RadioItemProps 52 | >(function MenuRadioItem(props, ref) { 53 | const { children, ...rest } = props; 54 | return ( 55 | 56 | 57 | 58 | 59 | 60 | 61 | {children} 62 | 63 | ); 64 | }); 65 | 66 | export const MenuItemGroup = forwardRef< 67 | HTMLDivElement, 68 | ChakraMenu.ItemGroupProps 69 | >(function MenuItemGroup(props, ref) { 70 | const { title, children, ...rest } = props; 71 | return ( 72 | 73 | {title && ( 74 | 75 | {title} 76 | 77 | )} 78 | {children} 79 | 80 | ); 81 | }); 82 | 83 | export interface MenuTriggerItemProps extends ChakraMenu.ItemProps { 84 | startIcon?: React.ReactNode; 85 | } 86 | 87 | export const MenuTriggerItem = forwardRef( 88 | function MenuTriggerItem(props, ref) { 89 | const { startIcon, children, ...rest } = props; 90 | return ( 91 | 92 | {startIcon} 93 | {children} 94 | 95 | 96 | ); 97 | }, 98 | ); 99 | 100 | export const MenuRadioItemGroup = ChakraMenu.RadioItemGroup; 101 | export const MenuContextTrigger = ChakraMenu.ContextTrigger; 102 | export const MenuRoot = ChakraMenu.Root; 103 | export const MenuSeparator = ChakraMenu.Separator; 104 | 105 | export const MenuItem = ChakraMenu.Item; 106 | export const MenuItemText = ChakraMenu.ItemText; 107 | export const MenuItemCommand = ChakraMenu.ItemCommand; 108 | export const MenuTrigger = ChakraMenu.Trigger; 109 | -------------------------------------------------------------------------------- /app/components/ui/native-select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { NativeSelect as Select } from "@chakra-ui/react"; 4 | import { forwardRef, useMemo } from "react"; 5 | 6 | interface NativeSelectRootProps extends Select.RootProps { 7 | icon?: React.ReactNode; 8 | } 9 | 10 | export const NativeSelectRoot = forwardRef< 11 | HTMLDivElement, 12 | NativeSelectRootProps 13 | >(function NativeSelect(props, ref) { 14 | const { icon, children, ...rest } = props; 15 | return ( 16 | 17 | {children} 18 | {icon} 19 | 20 | ); 21 | }); 22 | 23 | interface NativeSelectItem { 24 | value: string; 25 | label: string; 26 | disabled?: boolean; 27 | } 28 | 29 | interface NativeSelectField extends Select.FieldProps { 30 | items?: Array; 31 | } 32 | 33 | export const NativeSelectField = forwardRef< 34 | HTMLSelectElement, 35 | NativeSelectField 36 | >(function NativeSelectField(props, ref) { 37 | const { items: itemsProp, children, ...rest } = props; 38 | 39 | const items = useMemo( 40 | () => 41 | itemsProp?.map((item) => 42 | typeof item === "string" ? { label: item, value: item } : item, 43 | ), 44 | [itemsProp], 45 | ); 46 | 47 | return ( 48 | 49 | {children} 50 | {items?.map((item) => ( 51 | 54 | ))} 55 | 56 | ); 57 | }); 58 | -------------------------------------------------------------------------------- /app/components/ui/number-input.tsx: -------------------------------------------------------------------------------- 1 | import { NumberInput as ChakraNumberInput } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface NumberInputProps extends ChakraNumberInput.RootProps {} 5 | 6 | export const NumberInputRoot = forwardRef( 7 | function NumberInput(props, ref) { 8 | const { children, ...rest } = props; 9 | return ( 10 | 11 | {children} 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }, 19 | ); 20 | 21 | export const NumberInputField = ChakraNumberInput.Input; 22 | export const NumberInputScruber = ChakraNumberInput.Scrubber; 23 | export const NumberInputLabel = ChakraNumberInput.Label; 24 | -------------------------------------------------------------------------------- /app/components/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ButtonProps, TextProps } from "@chakra-ui/react"; 4 | import { 5 | Button, 6 | Pagination as ChakraPagination, 7 | IconButton, 8 | Text, 9 | createContext, 10 | usePaginationContext, 11 | } from "@chakra-ui/react"; 12 | import { forwardRef, useMemo } from "react"; 13 | import { 14 | HiChevronLeft, 15 | HiChevronRight, 16 | HiMiniEllipsisHorizontal, 17 | } from "react-icons/hi2"; 18 | import { LinkButton } from "./link-button"; 19 | 20 | interface ButtonVariantMap { 21 | current: ButtonProps["variant"]; 22 | default: ButtonProps["variant"]; 23 | ellipsis: ButtonProps["variant"]; 24 | } 25 | 26 | type PaginationVariant = "outline" | "solid" | "subtle"; 27 | 28 | interface ButtonVariantContext { 29 | size: ButtonProps["size"]; 30 | variantMap: ButtonVariantMap; 31 | getHref?: (page: number) => string; 32 | } 33 | 34 | const [RootPropsProvider, useRootProps] = createContext({ 35 | name: "RootPropsProvider", 36 | }); 37 | 38 | export interface PaginationRootProps 39 | extends Omit { 40 | size?: ButtonProps["size"]; 41 | variant?: PaginationVariant; 42 | getHref?: (page: number) => string; 43 | } 44 | 45 | const variantMap: Record = { 46 | outline: { default: "ghost", ellipsis: "plain", current: "outline" }, 47 | solid: { default: "outline", ellipsis: "outline", current: "solid" }, 48 | subtle: { default: "ghost", ellipsis: "plain", current: "subtle" }, 49 | }; 50 | 51 | export const PaginationRoot = forwardRef( 52 | function PaginationRoot(props, ref) { 53 | const { size = "sm", variant = "outline", getHref, ...rest } = props; 54 | return ( 55 | 58 | 63 | 64 | ); 65 | }, 66 | ); 67 | 68 | export const PaginationEllipsis = forwardRef< 69 | HTMLDivElement, 70 | ChakraPagination.EllipsisProps 71 | >(function PaginationEllipsis(props, ref) { 72 | const { size, variantMap } = useRootProps(); 73 | return ( 74 | 75 | 78 | 79 | ); 80 | }); 81 | 82 | export const PaginationItem = forwardRef< 83 | HTMLButtonElement, 84 | ChakraPagination.ItemProps 85 | >(function PaginationItem(props, ref) { 86 | const { page } = usePaginationContext(); 87 | const { size, variantMap, getHref } = useRootProps(); 88 | 89 | const current = page === props.value; 90 | const variant = current ? variantMap.current : variantMap.default; 91 | 92 | if (getHref) { 93 | return ( 94 | 95 | {props.value} 96 | 97 | ); 98 | } 99 | 100 | return ( 101 | 102 | 105 | 106 | ); 107 | }); 108 | 109 | export const PaginationPrevTrigger = forwardRef< 110 | HTMLButtonElement, 111 | ChakraPagination.PrevTriggerProps 112 | >(function PaginationPrevTrigger(props, ref) { 113 | const { size, variantMap, getHref } = useRootProps(); 114 | const { previousPage } = usePaginationContext(); 115 | 116 | if (getHref) { 117 | return ( 118 | 123 | 124 | 125 | ); 126 | } 127 | 128 | return ( 129 | 130 | 131 | 132 | 133 | 134 | ); 135 | }); 136 | 137 | export const PaginationNextTrigger = forwardRef< 138 | HTMLButtonElement, 139 | ChakraPagination.NextTriggerProps 140 | >(function PaginationNextTrigger(props, ref) { 141 | const { size, variantMap, getHref } = useRootProps(); 142 | const { nextPage } = usePaginationContext(); 143 | 144 | if (getHref) { 145 | return ( 146 | 151 | 152 | 153 | ); 154 | } 155 | 156 | return ( 157 | 158 | 159 | 160 | 161 | 162 | ); 163 | }); 164 | 165 | export const PaginationItems = (props: React.HTMLAttributes) => { 166 | return ( 167 | 168 | {({ pages }) => 169 | pages.map((page, index) => { 170 | return page.type === "ellipsis" ? ( 171 | 172 | ) : ( 173 | 179 | ); 180 | }) 181 | } 182 | 183 | ); 184 | }; 185 | 186 | interface PageTextProps extends TextProps { 187 | format?: "short" | "compact" | "long"; 188 | } 189 | 190 | export const PaginationPageText = forwardRef< 191 | HTMLParagraphElement, 192 | PageTextProps 193 | >(function PaginationPageText(props, ref) { 194 | const { format = "compact", ...rest } = props; 195 | const { page, pages, pageRange, count } = usePaginationContext(); 196 | const content = useMemo(() => { 197 | if (format === "short") return `${page} / ${pages.length}`; 198 | if (format === "compact") return `${page} of ${pages.length}`; 199 | return `${pageRange.start + 1} - ${pageRange.end} of ${count}`; 200 | }, [format, page, pages.length, pageRange, count]); 201 | 202 | return ( 203 | 204 | {content} 205 | 206 | ); 207 | }); 208 | -------------------------------------------------------------------------------- /app/components/ui/password-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { 4 | ButtonProps, 5 | GroupProps, 6 | InputProps, 7 | StackProps, 8 | } from "@chakra-ui/react"; 9 | import { 10 | Box, 11 | HStack, 12 | IconButton, 13 | Input, 14 | Stack, 15 | mergeRefs, 16 | useControllableState, 17 | } from "@chakra-ui/react"; 18 | import { forwardRef, useRef } from "react"; 19 | import { LuEye, LuEyeOff } from "react-icons/lu"; 20 | import { InputGroup } from "./input-group"; 21 | 22 | export interface PasswordVisibilityProps { 23 | defaultVisible?: boolean; 24 | visible?: boolean; 25 | onVisibleChange?: (visible: boolean) => void; 26 | visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }; 27 | } 28 | 29 | export interface PasswordInputProps 30 | extends InputProps, 31 | PasswordVisibilityProps { 32 | rootProps?: GroupProps; 33 | } 34 | 35 | export const PasswordInput = forwardRef( 36 | function PasswordInput(props, ref) { 37 | const { 38 | rootProps, 39 | defaultVisible, 40 | visible: visibleProp, 41 | onVisibleChange, 42 | visibilityIcon = { on: , off: }, 43 | ...rest 44 | } = props; 45 | 46 | const [visible, setVisible] = useControllableState({ 47 | value: visibleProp, 48 | defaultValue: defaultVisible || false, 49 | onChange: onVisibleChange, 50 | }); 51 | 52 | const inputRef = useRef(null); 53 | 54 | return ( 55 | { 61 | if (rest.disabled) return; 62 | if (e.button !== 0) return; 63 | e.preventDefault(); 64 | setVisible(!visible); 65 | }} 66 | > 67 | {visible ? visibilityIcon.off : visibilityIcon.on} 68 | 69 | } 70 | {...rootProps} 71 | > 72 | 77 | 78 | ); 79 | }, 80 | ); 81 | 82 | const VisibilityTrigger = forwardRef( 83 | function VisibilityTrigger(props, ref) { 84 | return ( 85 | 96 | ); 97 | }, 98 | ); 99 | 100 | interface PasswordStrengthMeterProps extends StackProps { 101 | max?: number; 102 | value: number; 103 | } 104 | 105 | export const PasswordStrengthMeter = forwardRef< 106 | HTMLDivElement, 107 | PasswordStrengthMeterProps 108 | >(function PasswordStrengthMeter(props, ref) { 109 | const { max = 4, value, ...rest } = props; 110 | 111 | const percent = (value / max) * 100; 112 | const { label, colorPalette } = getColorPalette(percent); 113 | 114 | return ( 115 | 116 | 117 | {Array.from({ length: max }).map((_, index) => ( 118 | 131 | ))} 132 | 133 | {label && {label}} 134 | 135 | ); 136 | }); 137 | 138 | function getColorPalette(percent: number) { 139 | switch (true) { 140 | case percent < 33: 141 | return { label: "Low", colorPalette: "red" }; 142 | case percent < 66: 143 | return { label: "Medium", colorPalette: "orange" }; 144 | default: 145 | return { label: "High", colorPalette: "green" }; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /app/components/ui/pin-input.tsx: -------------------------------------------------------------------------------- 1 | import { PinInput as ChakraPinInput, Group } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface PinInputProps extends ChakraPinInput.RootProps { 5 | rootRef?: React.Ref; 6 | count?: number; 7 | inputProps?: React.InputHTMLAttributes; 8 | attached?: boolean; 9 | } 10 | 11 | export const PinInput = forwardRef( 12 | function PinInput(props, ref) { 13 | const { count = 4, inputProps, rootRef, attached, ...rest } = props; 14 | return ( 15 | 16 | 17 | 18 | 19 | {Array.from({ length: count }).map((_, index) => ( 20 | 21 | ))} 22 | 23 | 24 | 25 | ); 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /app/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import { Popover as ChakraPopover, Portal } from "@chakra-ui/react"; 2 | import { CloseButton } from "./close-button"; 3 | import { forwardRef } from "react"; 4 | 5 | interface PopoverContentProps extends ChakraPopover.ContentProps { 6 | portalled?: boolean; 7 | portalRef?: React.RefObject; 8 | } 9 | 10 | export const PopoverContent = forwardRef( 11 | function PopoverContent(props, ref) { 12 | const { portalled = true, portalRef, ...rest } = props; 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }, 21 | ); 22 | 23 | export const PopoverArrow = forwardRef< 24 | HTMLDivElement, 25 | ChakraPopover.ArrowProps 26 | >(function PopoverArrow(props, ref) { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | }); 33 | 34 | export const PopoverCloseTrigger = forwardRef< 35 | HTMLButtonElement, 36 | ChakraPopover.CloseTriggerProps 37 | >(function PopoverCloseTrigger(props, ref) { 38 | return ( 39 | 47 | 48 | 49 | ); 50 | }); 51 | 52 | export const PopoverTitle = ChakraPopover.Title; 53 | export const PopoverDescription = ChakraPopover.Description; 54 | export const PopoverFooter = ChakraPopover.Footer; 55 | export const PopoverHeader = ChakraPopover.Header; 56 | export const PopoverRoot = ChakraPopover.Root; 57 | export const PopoverBody = ChakraPopover.Body; 58 | export const PopoverTrigger = ChakraPopover.Trigger; 59 | -------------------------------------------------------------------------------- /app/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | import { Progress as ChakraProgress, IconButton } from "@chakra-ui/react"; 2 | import { ToggleTip } from "./toggle-tip"; 3 | import { forwardRef } from "react"; 4 | import { HiOutlineInformationCircle } from "react-icons/hi"; 5 | 6 | export const ProgressBar = forwardRef< 7 | HTMLDivElement, 8 | ChakraProgress.TrackProps 9 | >(function ProgressBar(props, ref) { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }); 16 | 17 | export const ProgressRoot = ChakraProgress.Root; 18 | export const ProgressValueText = ChakraProgress.ValueText; 19 | 20 | export interface ProgressLabelProps extends ChakraProgress.LabelProps { 21 | info?: React.ReactNode; 22 | } 23 | 24 | export const ProgressLabel = forwardRef( 25 | function ProgressLabel(props, ref) { 26 | const { children, info, ...rest } = props; 27 | return ( 28 | 29 | {children} 30 | {info && ( 31 | 32 | 33 | 34 | 35 | 36 | )} 37 | 38 | ); 39 | }, 40 | ); 41 | -------------------------------------------------------------------------------- /app/components/ui/prose.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { chakra } from "@chakra-ui/react"; 4 | 5 | export const Prose = chakra("div", { 6 | base: { 7 | color: "fg.muted", 8 | maxWidth: "65ch", 9 | fontSize: "sm", 10 | lineHeight: "1.7em", 11 | "& p": { 12 | marginTop: "1em", 13 | marginBottom: "1em", 14 | }, 15 | "& blockquote": { 16 | marginTop: "1.285em", 17 | marginBottom: "1.285em", 18 | paddingInline: "1.285em", 19 | borderInlineStartWidth: "0.25em", 20 | }, 21 | "& a": { 22 | color: "fg", 23 | textDecoration: "underline", 24 | textUnderlineOffset: "3px", 25 | textDecorationThickness: "2px", 26 | textDecorationColor: "border.muted", 27 | fontWeight: "500", 28 | }, 29 | "& strong": { 30 | fontWeight: "600", 31 | }, 32 | "& a strong": { 33 | color: "inherit", 34 | }, 35 | "& h1": { 36 | fontSize: "2.15em", 37 | letterSpacing: "-0.02em", 38 | marginTop: "0", 39 | marginBottom: "0.8em", 40 | lineHeight: "1.2em", 41 | }, 42 | "& h2": { 43 | fontSize: "1.4em", 44 | letterSpacing: "-0.02em", 45 | marginTop: "1.6em", 46 | marginBottom: "0.8em", 47 | lineHeight: "1.4em", 48 | }, 49 | "& h3": { 50 | fontSize: "1.285em", 51 | letterSpacing: "-0.01em", 52 | marginTop: "1.5em", 53 | marginBottom: "0.4em", 54 | lineHeight: "1.5em", 55 | }, 56 | "& h4": { 57 | marginTop: "1.4em", 58 | marginBottom: "0.5em", 59 | letterSpacing: "-0.01em", 60 | lineHeight: "1.5em", 61 | }, 62 | "& img": { 63 | marginTop: "1.7em", 64 | marginBottom: "1.7em", 65 | borderRadius: "lg", 66 | boxShadow: "inset", 67 | }, 68 | "& picture": { 69 | marginTop: "1.7em", 70 | marginBottom: "1.7em", 71 | }, 72 | "& picture > img": { 73 | marginTop: "0", 74 | marginBottom: "0", 75 | }, 76 | "& video": { 77 | marginTop: "1.7em", 78 | marginBottom: "1.7em", 79 | }, 80 | "& kbd": { 81 | fontSize: "0.85em", 82 | borderRadius: "xs", 83 | paddingTop: "0.15em", 84 | paddingBottom: "0.15em", 85 | paddingInlineEnd: "0.35em", 86 | paddingInlineStart: "0.35em", 87 | fontFamily: "inherit", 88 | color: "fg.muted", 89 | "--shadow": "colors.border", 90 | boxShadow: "0 0 0 1px var(--shadow),0 1px 0 1px var(--shadow)", 91 | }, 92 | "& code": { 93 | fontSize: "0.925em", 94 | letterSpacing: "-0.01em", 95 | borderRadius: "md", 96 | borderWidth: "1px", 97 | padding: "0.25em", 98 | }, 99 | "& pre code": { 100 | fontSize: "inherit", 101 | letterSpacing: "inherit", 102 | borderWidth: "inherit", 103 | padding: "0", 104 | }, 105 | "& h2 code": { 106 | fontSize: "0.9em", 107 | }, 108 | "& h3 code": { 109 | fontSize: "0.8em", 110 | }, 111 | "& pre": { 112 | backgroundColor: "bg.subtle", 113 | marginTop: "1.6em", 114 | marginBottom: "1.6em", 115 | borderRadius: "md", 116 | fontSize: "0.9em", 117 | paddingTop: "0.65em", 118 | paddingBottom: "0.65em", 119 | paddingInlineEnd: "1em", 120 | paddingInlineStart: "1em", 121 | overflowX: "auto", 122 | fontWeight: "400", 123 | }, 124 | "& ol": { 125 | marginTop: "1em", 126 | marginBottom: "1em", 127 | paddingInlineStart: "1.5em", 128 | }, 129 | "& ul": { 130 | marginTop: "1em", 131 | marginBottom: "1em", 132 | paddingInlineStart: "1.5em", 133 | }, 134 | "& li": { 135 | marginTop: "0.285em", 136 | marginBottom: "0.285em", 137 | }, 138 | "& ol > li": { 139 | paddingInlineStart: "0.4em", 140 | listStyleType: "decimal", 141 | "&::marker": { 142 | color: "fg.muted", 143 | }, 144 | }, 145 | "& ul > li": { 146 | paddingInlineStart: "0.4em", 147 | listStyleType: "disc", 148 | "&::marker": { 149 | color: "fg.muted", 150 | }, 151 | }, 152 | "& > ul > li p": { 153 | marginTop: "0.5em", 154 | marginBottom: "0.5em", 155 | }, 156 | "& > ul > li > p:first-of-type": { 157 | marginTop: "1em", 158 | }, 159 | "& > ul > li > p:last-of-type": { 160 | marginBottom: "1em", 161 | }, 162 | "& > ol > li > p:first-of-type": { 163 | marginTop: "1em", 164 | }, 165 | "& > ol > li > p:last-of-type": { 166 | marginBottom: "1em", 167 | }, 168 | "& ul ul, ul ol, ol ul, ol ol": { 169 | marginTop: "0.5em", 170 | marginBottom: "0.5em", 171 | }, 172 | "& dl": { 173 | marginTop: "1em", 174 | marginBottom: "1em", 175 | }, 176 | "& dt": { 177 | fontWeight: "600", 178 | marginTop: "1em", 179 | }, 180 | "& dd": { 181 | marginTop: "0.285em", 182 | paddingInlineStart: "1.5em", 183 | }, 184 | "& hr": { 185 | marginTop: "2.25em", 186 | marginBottom: "2.25em", 187 | }, 188 | "& :is(h1,h2,h3,h4,h5,hr) + *": { 189 | marginTop: "0", 190 | }, 191 | "& table": { 192 | width: "100%", 193 | tableLayout: "auto", 194 | textAlign: "start", 195 | lineHeight: "1.5em", 196 | marginTop: "2em", 197 | marginBottom: "2em", 198 | }, 199 | "& thead": { 200 | borderBottomWidth: "1px", 201 | color: "fg", 202 | }, 203 | "& tbody tr": { 204 | borderBottomWidth: "1px", 205 | borderBottomColor: "border", 206 | }, 207 | "& thead th": { 208 | paddingInlineEnd: "1em", 209 | paddingBottom: "0.65em", 210 | paddingInlineStart: "1em", 211 | fontWeight: "medium", 212 | textAlign: "start", 213 | }, 214 | "& thead th:first-of-type": { 215 | paddingInlineStart: "0", 216 | }, 217 | "& thead th:last-of-type": { 218 | paddingInlineEnd: "0", 219 | }, 220 | "& tbody td, tfoot td": { 221 | paddingTop: "0.65em", 222 | paddingInlineEnd: "1em", 223 | paddingBottom: "0.65em", 224 | paddingInlineStart: "1em", 225 | }, 226 | "& tbody td:first-of-type, tfoot td:first-of-type": { 227 | paddingInlineStart: "0", 228 | }, 229 | "& tbody td:last-of-type, tfoot td:last-of-type": { 230 | paddingInlineEnd: "0", 231 | }, 232 | "& figure": { 233 | marginTop: "1.625em", 234 | marginBottom: "1.625em", 235 | }, 236 | "& figure > *": { 237 | marginTop: "0", 238 | marginBottom: "0", 239 | }, 240 | "& figcaption": { 241 | fontSize: "0.85em", 242 | lineHeight: "1.25em", 243 | marginTop: "0.85em", 244 | color: "fg.muted", 245 | }, 246 | "& h1, h2, h3, h4": { 247 | color: "fg", 248 | fontWeight: "600", 249 | }, 250 | }, 251 | variants: { 252 | size: { 253 | md: { 254 | fontSize: "sm", 255 | }, 256 | lg: { 257 | fontSize: "md", 258 | }, 259 | }, 260 | }, 261 | defaultVariants: { 262 | size: "md", 263 | }, 264 | }); 265 | -------------------------------------------------------------------------------- /app/components/ui/provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; 4 | import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode"; 5 | 6 | export function Provider(props: ColorModeProviderProps) { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/components/ui/radio-card.tsx: -------------------------------------------------------------------------------- 1 | import { RadioCard } from "@chakra-ui/react"; 2 | import { Fragment, forwardRef } from "react"; 3 | 4 | interface RadioCardItemProps extends RadioCard.ItemProps { 5 | icon?: React.ReactElement; 6 | label?: React.ReactNode; 7 | description?: React.ReactNode; 8 | addon?: React.ReactNode; 9 | indicator?: React.ReactNode | null; 10 | indicatorPlacement?: "start" | "end" | "inside"; 11 | inputProps?: React.InputHTMLAttributes; 12 | } 13 | 14 | export const RadioCardItem = forwardRef( 15 | function RadioCardItem(props, ref) { 16 | const { 17 | inputProps, 18 | label, 19 | description, 20 | addon, 21 | icon, 22 | indicator = , 23 | indicatorPlacement = "end", 24 | ...rest 25 | } = props; 26 | 27 | const hasContent = label || description || icon; 28 | const ContentWrapper = indicator ? RadioCard.ItemContent : Fragment; 29 | 30 | return ( 31 | 32 | 33 | 34 | {indicatorPlacement === "start" && indicator} 35 | {hasContent && ( 36 | 37 | {icon} 38 | {label && {label}} 39 | {description && ( 40 | 41 | {description} 42 | 43 | )} 44 | {indicatorPlacement === "inside" && indicator} 45 | 46 | )} 47 | {indicatorPlacement === "end" && indicator} 48 | 49 | {addon && {addon}} 50 | 51 | ); 52 | }, 53 | ); 54 | 55 | export const RadioCardRoot = RadioCard.Root; 56 | export const RadioCardLabel = RadioCard.Label; 57 | export const RadioCardItemIndicator = RadioCard.ItemIndicator; 58 | -------------------------------------------------------------------------------- /app/components/ui/radio.tsx: -------------------------------------------------------------------------------- 1 | import { RadioGroup as ChakraRadioGroup } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface RadioProps extends ChakraRadioGroup.ItemProps { 5 | rootRef?: React.Ref; 6 | inputProps?: React.InputHTMLAttributes; 7 | } 8 | 9 | export const Radio = forwardRef( 10 | function Radio(props, ref) { 11 | const { children, inputProps, rootRef, ...rest } = props; 12 | return ( 13 | 14 | 15 | 16 | {children && ( 17 | {children} 18 | )} 19 | 20 | ); 21 | }, 22 | ); 23 | 24 | export const RadioGroup = ChakraRadioGroup.Root; 25 | -------------------------------------------------------------------------------- /app/components/ui/rating.tsx: -------------------------------------------------------------------------------- 1 | import { RatingGroup } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface RatingProps extends RatingGroup.RootProps { 5 | icon?: React.ReactElement; 6 | count?: number; 7 | label?: React.ReactNode; 8 | } 9 | 10 | export const Rating = forwardRef( 11 | function Rating(props, ref) { 12 | const { icon, count = 5, label, ...rest } = props; 13 | return ( 14 | 15 | {label && {label}} 16 | 17 | 18 | {Array.from({ length: count }).map((_, index) => ( 19 | 20 | 21 | 22 | ))} 23 | 24 | 25 | ); 26 | }, 27 | ); 28 | -------------------------------------------------------------------------------- /app/components/ui/segmented-control.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { For, SegmentGroup } from "@chakra-ui/react"; 4 | import { forwardRef, useMemo } from "react"; 5 | 6 | interface Item { 7 | value: string; 8 | label: React.ReactNode; 9 | disabled?: boolean; 10 | } 11 | 12 | export interface SegmentedControlProps extends SegmentGroup.RootProps { 13 | items: Array; 14 | } 15 | 16 | function normalize(items: Array): Item[] { 17 | return items.map((item) => { 18 | if (typeof item === "string") return { value: item, label: item }; 19 | return item; 20 | }); 21 | } 22 | 23 | export const SegmentedControl = forwardRef< 24 | HTMLDivElement, 25 | SegmentedControlProps 26 | >(function SegmentedControl(props, ref) { 27 | const { items, ...rest } = props; 28 | const data = useMemo(() => normalize(items), [items]); 29 | 30 | return ( 31 | 32 | 33 | 34 | {(item) => ( 35 | 40 | {item.label} 41 | 42 | 43 | )} 44 | 45 | 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /app/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { CollectionItem } from "@chakra-ui/react"; 4 | import { Select as ChakraSelect, Portal } from "@chakra-ui/react"; 5 | import { CloseButton } from "./close-button"; 6 | import { forwardRef } from "react"; 7 | 8 | interface SelectTriggerProps extends ChakraSelect.ControlProps { 9 | clearable?: boolean; 10 | } 11 | 12 | export const SelectTrigger = forwardRef( 13 | function SelectTrigger(props, ref) { 14 | const { children, clearable, ...rest } = props; 15 | return ( 16 | 17 | {children} 18 | 19 | {clearable && } 20 | 21 | 22 | 23 | ); 24 | }, 25 | ); 26 | 27 | const SelectClearTrigger = forwardRef< 28 | HTMLButtonElement, 29 | ChakraSelect.ClearTriggerProps 30 | >(function SelectClearTrigger(props, ref) { 31 | return ( 32 | 33 | 40 | 41 | ); 42 | }); 43 | 44 | interface SelectContentProps extends ChakraSelect.ContentProps { 45 | portalled?: boolean; 46 | portalRef?: React.RefObject; 47 | } 48 | 49 | export const SelectContent = forwardRef( 50 | function SelectContent(props, ref) { 51 | const { portalled = true, portalRef, ...rest } = props; 52 | return ( 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | }, 60 | ); 61 | 62 | export const SelectItem = forwardRef( 63 | function SelectItem(props, ref) { 64 | const { item, children, ...rest } = props; 65 | return ( 66 | 67 | {children} 68 | 69 | 70 | ); 71 | }, 72 | ); 73 | 74 | interface SelectValueTextProps 75 | extends Omit { 76 | children?(items: CollectionItem[]): React.ReactNode; 77 | } 78 | 79 | export const SelectValueText = forwardRef< 80 | HTMLSpanElement, 81 | SelectValueTextProps 82 | >(function SelectValueText(props, ref) { 83 | const { children, ...rest } = props; 84 | return ( 85 | 86 | 87 | {(select) => { 88 | const items = select.selectedItems; 89 | if (items.length === 0) return props.placeholder; 90 | if (children) return children(items); 91 | if (items.length === 1) 92 | return select.collection.stringifyItem(items[0]); 93 | return `${items.length} selected`; 94 | }} 95 | 96 | 97 | ); 98 | }); 99 | 100 | export const SelectRoot = forwardRef( 101 | function SelectRoot(props, ref) { 102 | return ( 103 | 108 | {props.asChild ? ( 109 | props.children 110 | ) : ( 111 | <> 112 | 113 | {props.children} 114 | 115 | )} 116 | 117 | ); 118 | }, 119 | ) as ChakraSelect.RootComponent; 120 | 121 | interface SelectItemGroupProps extends ChakraSelect.ItemGroupProps { 122 | label: React.ReactNode; 123 | } 124 | 125 | export const SelectItemGroup = forwardRef( 126 | function SelectItemGroup(props, ref) { 127 | const { children, label, ...rest } = props; 128 | return ( 129 | 130 | {label} 131 | {children} 132 | 133 | ); 134 | }, 135 | ); 136 | 137 | export const SelectLabel = ChakraSelect.Label; 138 | export const SelectItemText = ChakraSelect.ItemText; 139 | -------------------------------------------------------------------------------- /app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | SkeletonProps as ChakraSkeletonProps, 3 | CircleProps, 4 | } from "@chakra-ui/react"; 5 | import { Skeleton as ChakraSkeleton, Circle, Stack } from "@chakra-ui/react"; 6 | import { forwardRef } from "react"; 7 | 8 | export interface SkeletonCircleProps extends ChakraSkeletonProps { 9 | size?: CircleProps["size"]; 10 | } 11 | 12 | export const SkeletonCircle = (props: SkeletonCircleProps) => { 13 | const { size, ...rest } = props; 14 | return ( 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export interface SkeletonTextProps extends ChakraSkeletonProps { 22 | noOfLines?: number; 23 | } 24 | 25 | export const SkeletonText = forwardRef( 26 | function SkeletonText(props, ref) { 27 | const { noOfLines = 3, gap, ...rest } = props; 28 | return ( 29 | 30 | {Array.from({ length: noOfLines }).map((_, index) => ( 31 | 38 | ))} 39 | 40 | ); 41 | }, 42 | ); 43 | 44 | export const Skeleton = ChakraSkeleton; 45 | -------------------------------------------------------------------------------- /app/components/ui/status.tsx: -------------------------------------------------------------------------------- 1 | import type { ColorPalette } from "@chakra-ui/react"; 2 | import { Status as ChakraStatus } from "@chakra-ui/react"; 3 | import { forwardRef } from "react"; 4 | 5 | type StatusValue = "success" | "error" | "warning" | "info"; 6 | 7 | export interface StatusProps extends ChakraStatus.RootProps { 8 | value?: StatusValue; 9 | } 10 | 11 | const statusMap: Record = { 12 | success: "green", 13 | error: "red", 14 | warning: "orange", 15 | info: "blue", 16 | }; 17 | 18 | export const Status = forwardRef( 19 | function Status(props, ref) { 20 | const { children, value = "info", ...rest } = props; 21 | const colorPalette = rest.colorPalette ?? statusMap[value]; 22 | return ( 23 | 24 | 25 | {children} 26 | 27 | ); 28 | }, 29 | ); 30 | -------------------------------------------------------------------------------- /app/components/ui/stepper-input.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, IconButton, NumberInput } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | import { LuMinus, LuPlus } from "react-icons/lu"; 4 | 5 | export interface StepperInputProps extends NumberInput.RootProps { 6 | label?: React.ReactNode; 7 | } 8 | 9 | export const StepperInput = forwardRef( 10 | function StepperInput(props, ref) { 11 | const { label, ...rest } = props; 12 | return ( 13 | 14 | {label && {label}} 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }, 23 | ); 24 | 25 | const DecrementTrigger = forwardRef< 26 | HTMLButtonElement, 27 | NumberInput.DecrementTriggerProps 28 | >(function DecrementTrigger(props, ref) { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }); 37 | 38 | const IncrementTrigger = forwardRef< 39 | HTMLButtonElement, 40 | NumberInput.IncrementTriggerProps 41 | >(function IncrementTrigger(props, ref) { 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }); 50 | -------------------------------------------------------------------------------- /app/components/ui/steps.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Steps as ChakraSteps } from "@chakra-ui/react"; 2 | import { LuCheck } from "react-icons/lu"; 3 | 4 | interface StepInfoProps { 5 | title?: React.ReactNode; 6 | description?: React.ReactNode; 7 | } 8 | 9 | export interface StepsItemProps 10 | extends Omit, 11 | StepInfoProps { 12 | completedIcon?: React.ReactNode; 13 | icon?: React.ReactNode; 14 | } 15 | 16 | export const StepsItem = (props: StepsItemProps) => { 17 | const { title, description, completedIcon, icon, ...rest } = props; 18 | return ( 19 | 20 | 21 | 22 | } 24 | incomplete={icon || } 25 | /> 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | const StepInfo = (props: StepInfoProps) => { 35 | const { title, description } = props; 36 | if (title && description) { 37 | return ( 38 | 39 | {title} 40 | {description} 41 | 42 | ); 43 | } 44 | return ( 45 | <> 46 | {title && {title}} 47 | {description && ( 48 | {description} 49 | )} 50 | 51 | ); 52 | }; 53 | 54 | interface StepsIndicatorProps { 55 | completedIcon: React.ReactNode; 56 | icon?: React.ReactNode; 57 | } 58 | 59 | export const StepsIndicator = (props: StepsIndicatorProps) => { 60 | const { icon = , completedIcon } = props; 61 | return ( 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export const StepsList = ChakraSteps.List; 69 | export const StepsRoot = ChakraSteps.Root; 70 | export const StepsContent = ChakraSteps.Content; 71 | export const StepsCompletedContent = ChakraSteps.CompletedContent; 72 | 73 | export const StepsNextTrigger = (props: ChakraSteps.NextTriggerProps) => { 74 | return ; 75 | }; 76 | 77 | export const StepsPrevTrigger = (props: ChakraSteps.PrevTriggerProps) => { 78 | return ; 79 | }; 80 | -------------------------------------------------------------------------------- /app/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import { Switch as ChakraSwitch } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface SwitchProps extends ChakraSwitch.RootProps { 5 | inputProps?: React.InputHTMLAttributes; 6 | rootRef?: React.Ref; 7 | trackLabel?: { on: React.ReactNode; off: React.ReactNode }; 8 | thumbLabel?: { on: React.ReactNode; off: React.ReactNode }; 9 | } 10 | 11 | export const Switch = forwardRef( 12 | function Switch(props, ref) { 13 | const { inputProps, children, rootRef, trackLabel, thumbLabel, ...rest } = 14 | props; 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | {thumbLabel && ( 22 | 23 | {thumbLabel?.on} 24 | 25 | )} 26 | 27 | {trackLabel && ( 28 | 29 | {trackLabel.on} 30 | 31 | )} 32 | 33 | {children != null && ( 34 | {children} 35 | )} 36 | 37 | ); 38 | }, 39 | ); 40 | -------------------------------------------------------------------------------- /app/components/ui/tag.tsx: -------------------------------------------------------------------------------- 1 | import { Tag as ChakraTag } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | 4 | export interface TagProps extends ChakraTag.RootProps { 5 | startElement?: React.ReactNode; 6 | endElement?: React.ReactNode; 7 | onClose?: VoidFunction; 8 | closable?: boolean; 9 | } 10 | 11 | export const Tag = forwardRef( 12 | function Tag(props, ref) { 13 | const { 14 | startElement, 15 | endElement, 16 | onClose, 17 | closable = !!onClose, 18 | children, 19 | ...rest 20 | } = props; 21 | 22 | return ( 23 | 24 | {startElement && ( 25 | {startElement} 26 | )} 27 | {children} 28 | {endElement && ( 29 | {endElement} 30 | )} 31 | {closable && ( 32 | 33 | 34 | 35 | )} 36 | 37 | ); 38 | }, 39 | ); 40 | -------------------------------------------------------------------------------- /app/components/ui/timeline.tsx: -------------------------------------------------------------------------------- 1 | import { Timeline as ChakraTimeline } from "@chakra-ui/react"; 2 | 3 | export const TimelineRoot = ChakraTimeline.Root; 4 | export const TimelineContent = ChakraTimeline.Content; 5 | export const TimelineItem = ChakraTimeline.Item; 6 | export const TimelineIndicator = ChakraTimeline.Indicator; 7 | export const TimelineTitle = ChakraTimeline.Title; 8 | export const TimelineDescription = ChakraTimeline.Description; 9 | 10 | export const TimelineConnector = (props: ChakraTimeline.IndicatorProps) => { 11 | return ( 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /app/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Toaster as ChakraToaster, 5 | Portal, 6 | Spinner, 7 | Stack, 8 | Toast, 9 | createToaster, 10 | } from "@chakra-ui/react"; 11 | 12 | export const toaster = createToaster({ 13 | placement: "bottom-end", 14 | pauseOnPageIdle: true, 15 | }); 16 | 17 | export const Toaster = () => { 18 | return ( 19 | 20 | 21 | {(toast) => ( 22 | 23 | {toast.type === "loading" ? ( 24 | 25 | ) : ( 26 | 27 | )} 28 | 29 | {toast.title && {toast.title}} 30 | {toast.description && ( 31 | {toast.description} 32 | )} 33 | 34 | {toast.action && ( 35 | {toast.action.label} 36 | )} 37 | {toast.meta?.closable && } 38 | 39 | )} 40 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /app/components/ui/toggle-tip.tsx: -------------------------------------------------------------------------------- 1 | import { Popover as ChakraPopover, IconButton, Portal } from "@chakra-ui/react"; 2 | import { forwardRef } from "react"; 3 | import { HiOutlineInformationCircle } from "react-icons/hi"; 4 | 5 | export interface ToggleTipProps extends ChakraPopover.RootProps { 6 | showArrow?: boolean; 7 | portalled?: boolean; 8 | portalRef?: React.RefObject; 9 | content?: React.ReactNode; 10 | } 11 | 12 | export const ToggleTip = forwardRef( 13 | function ToggleTip(props, ref) { 14 | const { 15 | showArrow, 16 | children, 17 | portalled = true, 18 | content, 19 | portalRef, 20 | ...rest 21 | } = props; 22 | 23 | return ( 24 | 28 | {children} 29 | 30 | 31 | 39 | {showArrow && ( 40 | 41 | 42 | 43 | )} 44 | {content} 45 | 46 | 47 | 48 | 49 | ); 50 | }, 51 | ); 52 | 53 | export const InfoTip = (props: Partial) => { 54 | const { children, ...rest } = props; 55 | return ( 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /app/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ButtonProps } from "@chakra-ui/react"; 4 | import { 5 | Button, 6 | Toggle as ChakraToggle, 7 | useToggleContext, 8 | } from "@chakra-ui/react"; 9 | import { forwardRef } from "react"; 10 | 11 | interface ToggleProps extends ChakraToggle.RootProps { 12 | variant?: keyof typeof variantMap; 13 | size?: ButtonProps["size"]; 14 | } 15 | 16 | const variantMap = { 17 | solid: { on: "solid", off: "outline" }, 18 | surface: { on: "surface", off: "outline" }, 19 | subtle: { on: "subtle", off: "ghost" }, 20 | ghost: { on: "subtle", off: "ghost" }, 21 | } as const; 22 | 23 | export const Toggle = forwardRef( 24 | function Toggle(props, ref) { 25 | const { variant = "subtle", size, children, ...rest } = props; 26 | const variantConfig = variantMap[variant]; 27 | 28 | return ( 29 | 30 | 31 | {children} 32 | 33 | 34 | ); 35 | }, 36 | ); 37 | 38 | interface ToggleBaseButtonProps extends Omit { 39 | variant: Record<"on" | "off", ButtonProps["variant"]>; 40 | } 41 | 42 | const ToggleBaseButton = forwardRef( 43 | function ToggleBaseButton(props, ref) { 44 | const toggle = useToggleContext(); 45 | const { variant, ...rest } = props; 46 | return ( 47 |