├── .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 |
26 |
27 |
28 |
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
;
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 |
42 |
43 |
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 |
52 | );
53 | },
54 | );
55 |
56 | export const ToggleIndicator = ChakraToggle.Indicator;
57 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2 | import {
3 | Links,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration,
8 | useNavigation,
9 | useNavigate,
10 | } from "react-router";
11 | import { useEffect } from "react";
12 | import { Provider } from "~/components/ui/provider";
13 | import { Toaster } from "~/components/ui/toaster";
14 | import { GlobalLoading } from "~/components/global-loading";
15 | import { setNavigate } from "~/utils/navigation";
16 |
17 | export function Layout({ children }: { children: React.ReactNode }) {
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
35 |
36 |
37 | );
38 | }
39 |
40 | const queryClient = new QueryClient();
41 |
42 | export default function App() {
43 | const navigation = useNavigation();
44 | const navigate = useNavigate();
45 | const isNavigating = Boolean(navigation.location);
46 |
47 | useEffect(() => {
48 | setNavigate(navigate);
49 | }, [navigate]);
50 |
51 | return (
52 |
53 |
54 |
55 |
56 | {isNavigating && }
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/app/routes.ts:
--------------------------------------------------------------------------------
1 | import {
2 | index,
3 | layout,
4 | route,
5 | type RouteConfig,
6 | } from "@react-router/dev/routes";
7 |
8 | export default [
9 | layout("routes/home_layout.tsx", [
10 | layout("routes/chat_layout.tsx", [
11 | index("routes/chat_index.tsx"),
12 | route("chat", "routes/chat.tsx"),
13 | ]),
14 | route("images", "routes/images.tsx"),
15 | route("images/new", "routes/images_new.tsx"),
16 | route("account", "routes/account.tsx"),
17 | ]),
18 | route("signin", "routes/signin.tsx"),
19 | route("signup", "routes/signup.tsx"),
20 | ] satisfies RouteConfig;
21 |
--------------------------------------------------------------------------------
/app/routes/account.tsx:
--------------------------------------------------------------------------------
1 | import { VStack, Text } from "@chakra-ui/react";
2 | import { useQuery } from "@tanstack/react-query";
3 | import api from "~/api";
4 | import { Avatar } from "~/components/ui/avatar";
5 | import { ColorModeButton } from "~/components/ui/color-mode";
6 | import { Skeleton } from "~/components/ui/skeleton";
7 |
8 | export default function Account() {
9 | const { data: account, isPending: isAccountPending } = useQuery({
10 | queryKey: ["account"],
11 | queryFn: async () => {
12 | return api.account();
13 | },
14 | });
15 | return (
16 |
17 |
18 | {isAccountPending ? (
19 |
20 | ) : (
21 |
22 | {account?.username}
23 |
24 | )}
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/app/routes/chat.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2 | import { useEffect, useState } from "react";
3 | import {
4 | Center,
5 | HStack,
6 | IconButton,
7 | Spacer,
8 | Text,
9 | VStack,
10 | } from "@chakra-ui/react";
11 | import {
12 | Skeleton,
13 | SkeletonCircle,
14 | SkeletonText,
15 | } from "~/components/ui/skeleton";
16 | import { useLocation, useNavigate, useSearchParams } from "react-router";
17 | import {
18 | ArrowLeftIcon,
19 | ArrowUpIcon,
20 | MessageSquareDashedIcon,
21 | } from "lucide-react";
22 | import { ChatBubbleMemo } from "~/components/chat-bubble";
23 | import { AutoResizedTextarea } from "~/components/auto-resized-textarea";
24 | import { Button } from "~/components/ui/button";
25 | import { EmptyState } from "~/components/ui/empty-state";
26 | import api from "~/api";
27 | import type { ConversationData, MessageData } from "~/types";
28 |
29 | export const handle = { deep: true };
30 |
31 | export async function clientLoader() {
32 | // prevent hydration mismatch
33 | return null;
34 | }
35 |
36 | export default function Chat() {
37 | const [content, setContent] = useState("");
38 | const [outputing, setOutputing] = useState("");
39 |
40 | const [searchParams] = useSearchParams();
41 | const conversationId = searchParams.get("id") as string;
42 |
43 | const queryClient = useQueryClient();
44 | const navigate = useNavigate();
45 | const location = useLocation();
46 |
47 | // make sure the state only used once
48 | useEffect(() => {
49 | if (location.state) {
50 | history.replaceState(null, "");
51 | }
52 | }, [location]);
53 |
54 | const { data: messages, isPending: isMessagesPending } = useQuery({
55 | queryKey: ["messages", conversationId],
56 | queryFn: async () => {
57 | return api.getMessages(conversationId);
58 | },
59 | initialData: location.state?.new ? [] : undefined,
60 | });
61 | const { data: conversation, isPending: isConversationsPending } = useQuery({
62 | queryKey: ["conversation", conversationId],
63 | queryFn: async () => {
64 | return api.getConversation(conversationId);
65 | },
66 | });
67 | const chatMutation = useMutation({
68 | mutationKey: ["chat", conversationId],
69 | mutationFn: async (content: string) => {
70 | setContent("");
71 | queryClient.setQueryData(
72 | ["messages", conversationId],
73 | (oldData: MessageData[]) => {
74 | return [
75 | ...oldData,
76 | {
77 | id: crypto.randomUUID(),
78 | role: "user",
79 | content,
80 | created_at: new Date().toISOString(),
81 | },
82 | ];
83 | },
84 | );
85 | setOutputing("...");
86 | const sendMessageData =
87 | messages?.map((message) => ({
88 | role: message.role,
89 | content: message.content,
90 | })) ?? [];
91 | sendMessageData.push({ role: "user", content: content });
92 | const resp = await api.chat(conversationId, sendMessageData);
93 | const reader = resp.body?.getReader();
94 | if (!reader) {
95 | return;
96 | }
97 | const decoder = new TextDecoder();
98 | let tmp = "";
99 | while (true) {
100 | const { done, value } = await reader.read();
101 | if (done) {
102 | break;
103 | }
104 | const text = decoder.decode(value);
105 | tmp += text;
106 | setOutputing(tmp);
107 | }
108 | const assistantMessage: MessageData = {
109 | id: crypto.randomUUID(),
110 | user_id: "",
111 | conversation_id: conversationId,
112 | role: "assistant",
113 | content: tmp,
114 | created_at: new Date().toISOString(),
115 | updated_at: new Date().toISOString(),
116 | };
117 | return assistantMessage;
118 | },
119 | async onSuccess(data) {
120 | setOutputing("");
121 | if (!data) return;
122 | queryClient.setQueryData(
123 | ["messages", conversationId],
124 | (oldData: MessageData[]) => {
125 | return [...oldData, data];
126 | },
127 | );
128 | if (
129 | conversation &&
130 | conversation.name === "" &&
131 | messages &&
132 | messages.length > 0
133 | ) {
134 | const messagesSend = messages.map((message) => {
135 | return {
136 | role: message.role,
137 | content: message.content,
138 | };
139 | });
140 | const topic = await api.summarize(conversationId, messagesSend);
141 | queryClient.setQueryData(
142 | ["conversation", conversationId],
143 | (oldData: ConversationData) => {
144 | return {
145 | ...oldData,
146 | name: topic,
147 | };
148 | },
149 | );
150 | }
151 | queryClient.invalidateQueries({
152 | queryKey: ["conversations"],
153 | });
154 | // update the conversation list
155 | // queryClient.setQueryData(
156 | // ["conversations"],
157 | // async (oldData: Conversation[]) => {
158 | // if (!conversation) return
159 | // // remove the conversation from the list
160 | // const data = oldData.filter(
161 | // (conversation) => conversation.id !== conversationId,
162 | // );
163 | // // add the conversation to the top
164 | // data.unshift(conversation);
165 | // return data;
166 | // },
167 | // );
168 | },
169 | onError() {
170 | setOutputing("");
171 | },
172 | });
173 |
174 | const reverseMessages = messages?.slice().reverse() ?? [];
175 |
176 | return (
177 |
178 |
179 |
183 | navigate("/", {
184 | viewTransition: true,
185 | })
186 | }
187 | >
188 |
189 |
190 | {isConversationsPending ? (
191 |
192 | ) : (
193 |
194 | {conversation?.name}
195 |
196 | )}
197 |
198 |
199 |
219 |
220 | {isMessagesPending && }
221 | {!isMessagesPending && reverseMessages.length === 0 && (
222 |
223 | }
225 | title="请输入消息"
226 | description="在下方输入框输入消息,然后按回车键发送"
227 | />
228 |
229 | )}
230 | {outputing !== "" && (
231 |
243 | )}
244 | {reverseMessages.map((message) => (
245 |
246 | ))}
247 |
248 |
249 |
250 | setContent(e.currentTarget.value)}
260 | />
261 |
268 |
269 |
270 |
271 | );
272 | }
273 |
274 | function ChatSkeleton() {
275 | return (
276 |
277 | {Array.from({ length: 3 }).map((_, index) => (
278 |
286 | {index % 2 !== 0 && }
287 |
288 |
289 |
290 |
291 |
292 | ))}
293 |
294 | );
295 | }
296 |
--------------------------------------------------------------------------------
/app/routes/chat_index.tsx:
--------------------------------------------------------------------------------
1 | import { Center } from "@chakra-ui/react";
2 | import { MessageSquareIcon } from "lucide-react";
3 | import { EmptyState } from "~/components/ui/empty-state";
4 |
5 | export default function Index() {
6 | return (
7 |
8 | }
10 | title="请选择一个会话"
11 | description="点击左侧的会话列表,开始聊天"
12 | />
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/routes/chat_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Center, Spinner, Stack } from "@chakra-ui/react";
2 | import { Outlet, useLocation, useSearchParams } from "react-router";
3 | import { HStack, IconButton, Spacer, Text, VStack } from "@chakra-ui/react";
4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5 | import { EllipsisVerticalIcon, PlusIcon, SmileIcon } from "lucide-react";
6 | import { useNavigate } from "react-router";
7 | import { Button } from "~/components/ui/button";
8 | import {
9 | MenuContent,
10 | MenuItem,
11 | MenuRoot,
12 | MenuTrigger,
13 | } from "~/components/ui/menu";
14 | import { toaster } from "~/components/ui/toaster";
15 | import { EmptyState } from "~/components/ui/empty-state";
16 | import api from "~/api";
17 | import type { ConversationData } from "~/types";
18 |
19 | export default function ChatLayout() {
20 | const queryClient = useQueryClient();
21 | const navigate = useNavigate();
22 | const location = useLocation();
23 | const [searchParams] = useSearchParams();
24 | const conversationId = searchParams.get("id");
25 |
26 | const { data: conversations, isPending: isConversationsPending } = useQuery({
27 | queryKey: ["conversations"],
28 | queryFn: async () => {
29 | return api.getConversations();
30 | },
31 | });
32 |
33 | const createConversationMuration = useMutation({
34 | mutationKey: ["createConversation"],
35 | mutationFn: async () => {
36 | return api.createConversation();
37 | },
38 | onSuccess(data) {
39 | queryClient.setQueryData(
40 | ["conversations"],
41 | (oldData: ConversationData[]) => {
42 | return [data, ...oldData];
43 | },
44 | );
45 | navigate(`/chat/?id=${data.id}`, {
46 | viewTransition: true,
47 | state: {
48 | new: true,
49 | },
50 | });
51 | },
52 | });
53 |
54 | const deleteConversationMutation = useMutation({
55 | mutationKey: ["deleteConversation"],
56 | mutationFn: async (conversationId: string) => {
57 | const promise = api.deleteConversation(conversationId);
58 | toaster.promise(promise, {
59 | success: {
60 | title: "删除成功",
61 | description: "会话已删除",
62 | },
63 | error: {
64 | title: "删除失败",
65 | description: "会话删除失败",
66 | },
67 | loading: {
68 | title: "删除中",
69 | description: "正在删除会话",
70 | },
71 | });
72 | return conversationId;
73 | },
74 | onSuccess(data) {
75 | queryClient.setQueryData(
76 | ["conversations"],
77 | (oldData: ConversationData[]) => {
78 | return oldData.filter((conversation) => conversation.id !== data);
79 | },
80 | );
81 | if (conversationId === data) {
82 | navigate("/", {
83 | viewTransition: true,
84 | });
85 | }
86 | },
87 | });
88 |
89 | return (
90 |
91 |
103 |
104 | 会话列表
105 |
106 |
115 |
116 |
133 | {conversations?.length === 0 && (
134 | }
136 | title="暂无会话"
137 | description="点击右上角按钮创建一个新会话"
138 | />
139 | )}
140 | {isConversationsPending && (
141 |
142 |
143 |
144 | )}
145 | {conversations?.map((converation) => {
146 | return (
147 |
192 | );
193 | })}
194 |
195 |
196 |
197 |
198 | );
199 | }
200 |
--------------------------------------------------------------------------------
/app/routes/home_layout.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, Stack, Text, VStack } from "@chakra-ui/react";
2 | import { BotIcon, BrushIcon, MessageSquareIcon, UserIcon } from "lucide-react";
3 | import {
4 | NavLink,
5 | Outlet,
6 | useLocation,
7 | useMatches,
8 | useNavigate,
9 | } from "react-router";
10 | import { Button } from "~/components/ui/button";
11 |
12 | const navigations = [
13 | { href: "/", title: "首页", icon: },
14 | { href: "/images/", title: "绘画", icon: },
15 | { href: "/account/", title: "我的", icon: },
16 | ];
17 |
18 | export default function HomeLayout() {
19 | const matches = useMatches();
20 |
21 | const location = useLocation();
22 | const navigate = useNavigate();
23 |
24 | // @ts-ignore
25 | const deep = matches.some((match) => match.handle?.deep);
26 |
27 | return (
28 |
29 |
37 |
38 |
39 |
40 | {navigations.map((item) => {
41 | let isActive = location.pathname === item.href;
42 | if (item.href === "/" && location.pathname === "/chat/") {
43 | isActive = true;
44 | }
45 | if (
46 | item.href === "/images/" &&
47 | location.pathname === "/images/new/"
48 | ) {
49 | isActive = true;
50 | }
51 | return (
52 |
58 | {() => {
59 | return (
60 |
68 | );
69 | }}
70 |
71 | );
72 | })}
73 |
74 |
75 |
81 | {navigations.map((item) => {
82 | let isActive = location.pathname === item.href;
83 | if (item.href === "/" && location.pathname === "/chat") {
84 | isActive = true;
85 | }
86 | return (
87 |
94 | {() => {
95 | return (
96 |
104 | );
105 | }}
106 |
107 | );
108 | })}
109 |
110 |
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/app/routes/images.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HStack,
3 | Spacer,
4 | VStack,
5 | Grid,
6 | GridItem,
7 | Center,
8 | Spinner,
9 | Card,
10 | Text,
11 | Stack,
12 | } from "@chakra-ui/react";
13 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
14 | import {
15 | DownloadIcon,
16 | InfoIcon,
17 | PlusIcon,
18 | SwatchBookIcon,
19 | Trash2Icon,
20 | } from "lucide-react";
21 | import { Link } from "react-router";
22 | import api from "~/api";
23 | import CustomImage from "~/components/custom-image";
24 | import { Button } from "~/components/ui/button";
25 | import { ClipboardIconButton, ClipboardRoot } from "~/components/ui/clipboard";
26 | import { EmptyState } from "~/components/ui/empty-state";
27 | import {
28 | PopoverArrow,
29 | PopoverBody,
30 | PopoverContent,
31 | PopoverRoot,
32 | PopoverTrigger,
33 | } from "~/components/ui/popover";
34 | import type { ImageResData } from "~/types";
35 |
36 | export default function Images() {
37 | const queryClient = useQueryClient();
38 |
39 | const { data: images, isPending: isImagesPending } = useQuery({
40 | queryKey: ["images"],
41 | queryFn: async () => {
42 | return api.getImages();
43 | },
44 | });
45 |
46 | const deleteImageMutation = useMutation({
47 | mutationKey: ["deleteImage"],
48 | mutationFn: async (id: string) => {
49 | await api.deleteImage(id);
50 | return id;
51 | },
52 | onSuccess: (id) => {
53 | queryClient.setQueryData(["images"], (images: ImageResData[]) => {
54 | return images.filter((image) => image.id !== id);
55 | });
56 | },
57 | });
58 |
59 | function downloadImage(src: string) {
60 | const a = document.createElement("a");
61 | a.href = src;
62 | a.download = "image.png";
63 | document.body.appendChild(a);
64 | a.click();
65 | document.body.removeChild(a);
66 | }
67 |
68 | return (
69 |
70 |
71 | 绘画列表
72 |
73 |
78 |
79 | {images?.length === 0 && (
80 | }
82 | title="暂无绘画"
83 | description="点击右上角或者下方按钮添加绘画"
84 | >
85 |
86 |
89 |
90 |
91 | )}
92 | {isImagesPending && (
93 |
94 |
95 |
96 | )}
97 |
122 | {images?.map((image) => (
123 |
124 |
125 |
130 |
131 |
132 | {image.prompt}
133 |
134 |
135 |
136 |
137 |
138 |
139 |
146 |
154 |
155 |
156 |
157 |
160 |
161 |
162 |
163 |
164 |
180 | {image.prompt}
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 | ))}
189 |
190 |
191 | );
192 | }
193 |
--------------------------------------------------------------------------------
/app/routes/images_new.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | Center,
4 | HStack,
5 | IconButton,
6 | Spacer,
7 | Spinner,
8 | Stack,
9 | Text,
10 | VStack,
11 | } from "@chakra-ui/react";
12 | import { useMutation } from "@tanstack/react-query";
13 | import {
14 | ArrowLeftIcon,
15 | DownloadIcon,
16 | DraftingCompassIcon,
17 | InfoIcon,
18 | SparklesIcon,
19 | } from "lucide-react";
20 | import { useState } from "react";
21 | import { useNavigate } from "react-router";
22 | import api from "~/api";
23 | import { AutoResizedTextarea } from "~/components/auto-resized-textarea";
24 | import CustomImage from "~/components/custom-image";
25 | import { Button } from "~/components/ui/button";
26 | import { ClipboardIconButton, ClipboardRoot } from "~/components/ui/clipboard";
27 | import { EmptyState } from "~/components/ui/empty-state";
28 | import {
29 | PopoverArrow,
30 | PopoverBody,
31 | PopoverContent,
32 | PopoverRoot,
33 | PopoverTrigger,
34 | } from "~/components/ui/popover";
35 | import type { ImageResData } from "~/types";
36 |
37 | export const handle = { deep: true };
38 |
39 | export async function clientLoader() {
40 | return null;
41 | }
42 |
43 | const prompts = [
44 | "a woman wearing a Victorian style dress, she has brown hair pull up in a tight bun, she walks through a foggy street at night, the full moon can barely be seen in the sky through the fog and cloud, she is lit the side by the orange light of a nearby lamp. close up on her face, she has piercing blue eyes, and is blushing",
45 | "Inkpunk Anime, inksketch in red and black, image of shinto shrine on top of a mountain, long flight of steps up to it, canyon, ravine, sunset, mist",
46 | "A tall, narrow house with a vibrant mustard-yellow facade, standing solitary on a rocky ledge. The buildingâs walls are aged, with visible cracks and patches of weathered paint, adding a rustic charm. Black-framed windows and a dark wooden door are set against the bold yellow, creating a stark, almost surreal contrast. Leafless, spindly trees surround the house, their branches stretching upward, almost touching the structure. The sky is a flat, desaturated beige, giving the scene an eerie, isolated mood, as if the house has been untouched for decades. Fine art photography style, with high detail on the textures of the stone, cracked paint, and bare branches.",
47 | "A vast snowy landscape with a lone traveler, equipped with a backpack and a helmet, walking towards a massive, derelict spaceship. The spaceship, appearing aged and worn out, has a prominent circular window emitting a glowing orange light. The sky is overcast, and the overall ambiance is cold and desolate.",
48 | ];
49 |
50 | function downloadImage(src: string) {
51 | const a = document.createElement("a");
52 | a.href = src;
53 | a.download = "image.png";
54 | document.body.appendChild(a);
55 | a.click();
56 | document.body.removeChild(a);
57 | }
58 |
59 | export default function ImagesNew() {
60 | const [prompt, setPrompt] = useState("");
61 | const [loadingPrompt, setLoadingPrompt] = useState("");
62 | const [imageResData, setImageResData] = useState();
63 | const navigate = useNavigate();
64 |
65 | const mutation = useMutation({
66 | mutationKey: ["newImages"],
67 | mutationFn: async (data: { prompt: string }) => {
68 | setLoadingPrompt(data.prompt);
69 | setPrompt("");
70 | return await api.generateImages(data.prompt);
71 | },
72 | onSuccess: (data) => {
73 | setImageResData(data);
74 | },
75 | });
76 |
77 | function randomPrompt() {
78 | const randomIndex = Math.floor(Math.random() * prompts.length);
79 | setPrompt(prompts[randomIndex]);
80 | mutation.mutate({ prompt: prompts[randomIndex] });
81 | }
82 |
83 | return (
84 |
85 |
86 | navigate(-1)}>
87 |
88 |
89 |
90 |
91 |
92 | {!imageResData && mutation.status === "idle" && (
93 | }
95 | title="请输入提示词"
96 | description="请在下方输入提示词,或者点击随机按钮生成"
97 | >
98 |
99 |
100 | )}
101 | {!imageResData && mutation.status === "pending" && (
102 |
103 |
104 |
105 |
106 |
107 | {loadingPrompt}
108 |
109 |
110 | )}
111 | {imageResData && (
112 |
113 |
118 |
119 | {loadingPrompt}
120 |
121 |
122 |
123 |
124 |
125 |
132 |
133 |
134 |
135 |
138 |
139 |
140 |
141 |
142 |
158 | {loadingPrompt}
159 |
160 |
161 |
162 |
163 |
164 |
165 | )}
166 |
167 |
168 |
169 | setPrompt(e.currentTarget.value)}
179 | />
180 |
187 |
188 |
189 |
190 | );
191 | }
192 |
--------------------------------------------------------------------------------
/app/routes/signin.tsx:
--------------------------------------------------------------------------------
1 | import { Heading, HStack, Input, Stack, VStack } from "@chakra-ui/react";
2 | import { zodResolver } from "@hookform/resolvers/zod";
3 | import { useMutation } from "@tanstack/react-query";
4 | import { Link, useNavigate } from "react-router";
5 | import { useForm } from "react-hook-form";
6 | import { z } from "zod";
7 | import api from "~/api";
8 | import { Button } from "~/components/ui/button";
9 | import { Field } from "~/components/ui/field";
10 | import { PasswordInput } from "~/components/ui/password-input";
11 | import { toaster } from "~/components/ui/toaster";
12 | import bgImage from "~/assets/wave01.svg";
13 |
14 | const schema = z.object({
15 | username: z.string().min(1, { message: "用户名不能为空" }),
16 | password: z.string().min(1, { message: "密码不能为空" }),
17 | });
18 |
19 | export default function Signin() {
20 | const {
21 | register,
22 | handleSubmit,
23 | formState: { errors },
24 | } = useForm>({
25 | resolver: zodResolver(schema),
26 | });
27 |
28 | const navigate = useNavigate();
29 |
30 | const mutation = useMutation({
31 | mutationKey: ["signin"],
32 | mutationFn: async (data: z.infer) => {
33 | return api.signin(data);
34 | },
35 | onSuccess(data) {
36 | localStorage.setItem("access_token", data.access_token);
37 | localStorage.setItem("refresh_token", data.refresh_token);
38 | toaster.success({
39 | title: "登录成功",
40 | description: "欢迎回来!",
41 | });
42 | navigate("/");
43 | },
44 | onError(error) {
45 | toaster.error({
46 | title: "登录失败",
47 | description: error.message,
48 | });
49 | },
50 | });
51 |
52 | const onSubmit = handleSubmit((data) => mutation.mutate(data));
53 |
54 | return (
55 |
56 |
93 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/app/routes/signup.tsx:
--------------------------------------------------------------------------------
1 | import { Heading, HStack, Input, Stack, VStack } from "@chakra-ui/react";
2 | import { zodResolver } from "@hookform/resolvers/zod";
3 | import { useMutation } from "@tanstack/react-query";
4 | import { Link, useNavigate } from "react-router";
5 | import { useForm } from "react-hook-form";
6 | import { z } from "zod";
7 | import api from "~/api";
8 | import { Button } from "~/components/ui/button";
9 | import { Field } from "~/components/ui/field";
10 | import { PasswordInput } from "~/components/ui/password-input";
11 | import { toaster } from "~/components/ui/toaster";
12 | import bgImage from "~/assets/wave02.svg";
13 |
14 | const schema = z.object({
15 | username: z.string().min(1, { message: "用户名不能为空" }),
16 | password: z.string().min(1, { message: "密码不能为空" }),
17 | });
18 |
19 | export default function Signup() {
20 | const {
21 | register,
22 | handleSubmit,
23 | formState: { errors },
24 | } = useForm>({
25 | resolver: zodResolver(schema),
26 | });
27 |
28 | const navigate = useNavigate();
29 |
30 | const mutation = useMutation({
31 | mutationKey: ["signin"],
32 | mutationFn: async (data: z.infer) => {
33 | return api.signup(data);
34 | },
35 | onSuccess(data) {
36 | toaster.success({
37 | title: "注册成功",
38 | description: "欢迎加入!",
39 | });
40 | navigate("/signin");
41 | },
42 | onError(error) {
43 | toaster.error({
44 | title: "注册失败",
45 | description: error.message,
46 | });
47 | },
48 | });
49 |
50 | const onSubmit = handleSubmit((data) => mutation.mutate(data));
51 |
52 | return (
53 |
54 |
91 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/app/types.ts:
--------------------------------------------------------------------------------
1 | export interface SigninRespData {
2 | access_token: string;
3 | refresh_token: string;
4 | }
5 |
6 | export interface UserData {
7 | id: string;
8 | username: string;
9 | }
10 |
11 | export interface ConversationData {
12 | id: string;
13 | name: string;
14 | pinned: boolean;
15 | created_at: string;
16 | updated_at: string;
17 | }
18 |
19 | export interface MessageData {
20 | id: string;
21 | conversation_id: string;
22 | user_id: string;
23 | role: string;
24 | content: string;
25 | created_at: string;
26 | updated_at: string;
27 | }
28 |
29 | export interface SendMessageData {
30 | role: string;
31 | content: string;
32 | }
33 |
34 | export interface ImageResData {
35 | id: string;
36 | url: string;
37 | blurhash: string;
38 | prompt: string;
39 | }
40 |
--------------------------------------------------------------------------------
/app/utils/blurhash-to-dataurl.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { decode } from "blurhash";
3 |
4 | export function blurHashToDataURL(
5 | hash: string | undefined,
6 | ): string | undefined {
7 | if (!hash) return undefined;
8 |
9 | const pixels = decode(hash, 32, 32);
10 | const dataURL = parsePixels(pixels, 32, 32);
11 | return dataURL;
12 | }
13 |
14 | // thanks to https://github.com/wheany/js-png-encoder
15 | function parsePixels(pixels: Uint8ClampedArray, width: number, height: number) {
16 | const pixelsString = [...pixels]
17 | .map((byte) => String.fromCharCode(byte))
18 | .join("");
19 | const pngString = generatePng(width, height, pixelsString);
20 | const dataURL =
21 | typeof Buffer !== "undefined"
22 | ? Buffer.from(getPngArray(pngString)).toString("base64")
23 | : btoa(pngString);
24 | return "data:image/png;base64," + dataURL;
25 | }
26 |
27 | function getPngArray(pngString: string) {
28 | const pngArray = new Uint8Array(pngString.length);
29 | for (let i = 0; i < pngString.length; i++) {
30 | pngArray[i] = pngString.charCodeAt(i);
31 | }
32 | return pngArray;
33 | }
34 |
35 | function generatePng(width: number, height: number, rgbaString: string) {
36 | const DEFLATE_METHOD = String.fromCharCode(0x78, 0x01);
37 | const CRC_TABLE: number[] = [];
38 | const SIGNATURE = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
39 | const NO_FILTER = String.fromCharCode(0);
40 |
41 | let n, c, k;
42 |
43 | // make crc table
44 | for (n = 0; n < 256; n++) {
45 | c = n;
46 | for (k = 0; k < 8; k++) {
47 | if (c & 1) {
48 | c = 0xedb88320 ^ (c >>> 1);
49 | } else {
50 | c = c >>> 1;
51 | }
52 | }
53 | CRC_TABLE[n] = c;
54 | }
55 |
56 | // Functions
57 | function inflateStore(data: string) {
58 | const MAX_STORE_LENGTH = 65535;
59 | let storeBuffer = "";
60 | let remaining;
61 | let blockType;
62 |
63 | for (let i = 0; i < data.length; i += MAX_STORE_LENGTH) {
64 | remaining = data.length - i;
65 | blockType = "";
66 |
67 | if (remaining <= MAX_STORE_LENGTH) {
68 | blockType = String.fromCharCode(0x01);
69 | } else {
70 | remaining = MAX_STORE_LENGTH;
71 | blockType = String.fromCharCode(0x00);
72 | }
73 | // little-endian
74 | storeBuffer +=
75 | blockType +
76 | String.fromCharCode(remaining & 0xff, (remaining & 0xff00) >>> 8);
77 | storeBuffer += String.fromCharCode(
78 | ~remaining & 0xff,
79 | (~remaining & 0xff00) >>> 8,
80 | );
81 |
82 | storeBuffer += data.substring(i, i + remaining);
83 | }
84 |
85 | return storeBuffer;
86 | }
87 |
88 | function adler32(data: string) {
89 | let MOD_ADLER = 65521;
90 | let a = 1;
91 | let b = 0;
92 |
93 | for (let i = 0; i < data.length; i++) {
94 | a = (a + data.charCodeAt(i)) % MOD_ADLER;
95 | b = (b + a) % MOD_ADLER;
96 | }
97 |
98 | return (b << 16) | a;
99 | }
100 |
101 | function updateCrc(crc: number, buf: string) {
102 | let c = crc;
103 | let b: number;
104 |
105 | for (let n = 0; n < buf.length; n++) {
106 | b = buf.charCodeAt(n);
107 | c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
108 | }
109 | return c;
110 | }
111 |
112 | function crc(buf: string) {
113 | return updateCrc(0xffffffff, buf) ^ 0xffffffff;
114 | }
115 |
116 | function dwordAsString(dword: number) {
117 | return String.fromCharCode(
118 | (dword & 0xff000000) >>> 24,
119 | (dword & 0x00ff0000) >>> 16,
120 | (dword & 0x0000ff00) >>> 8,
121 | dword & 0x000000ff,
122 | );
123 | }
124 |
125 | function createChunk(length: number, type: string, data: string) {
126 | const CRC = crc(type + data);
127 |
128 | return dwordAsString(length) + type + data + dwordAsString(CRC);
129 | }
130 |
131 | function createIHDR(width: number, height: number) {
132 | const IHDRdata =
133 | dwordAsString(width) +
134 | dwordAsString(height) +
135 | // bit depth
136 | String.fromCharCode(8) +
137 | // color type: 6=truecolor with alpha
138 | String.fromCharCode(6) +
139 | // compression method: 0=deflate, only allowed value
140 | String.fromCharCode(0) +
141 | // filtering: 0=adaptive, only allowed value
142 | String.fromCharCode(0) +
143 | // interlacing: 0=none
144 | String.fromCharCode(0);
145 |
146 | return createChunk(13, "IHDR", IHDRdata);
147 | }
148 |
149 | // PNG creations
150 |
151 | const IEND = createChunk(0, "IEND", "");
152 | const IHDR = createIHDR(width, height);
153 |
154 | let scanlines = "";
155 | let scanline;
156 |
157 | for (let y = 0; y < rgbaString.length; y += width * 4) {
158 | scanline = NO_FILTER;
159 | if (Array.isArray(rgbaString)) {
160 | for (let x = 0; x < width * 4; x++) {
161 | scanline += String.fromCharCode(rgbaString[y + x] & 0xff);
162 | }
163 | } else {
164 | scanline += rgbaString.substr(y, width * 4);
165 | }
166 | scanlines += scanline;
167 | }
168 |
169 | const compressedScanlines =
170 | DEFLATE_METHOD +
171 | inflateStore(scanlines) +
172 | dwordAsString(adler32(scanlines));
173 | const IDAT = createChunk(
174 | compressedScanlines.length,
175 | "IDAT",
176 | compressedScanlines,
177 | );
178 |
179 | const pngString = SIGNATURE + IHDR + IDAT + IEND;
180 | return pngString;
181 | }
182 |
--------------------------------------------------------------------------------
/app/utils/navigation.ts:
--------------------------------------------------------------------------------
1 | import type { NavigateFunction } from "react-router";
2 |
3 | let navigate: NavigateFunction;
4 |
5 | export const setNavigate = (nav: NavigateFunction) => {
6 | navigate = nav;
7 | };
8 |
9 | export const getNavigate = () => navigate;
10 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": false,
5 | "clientKind": "git",
6 | "useIgnoreFile": false
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": []
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "tab"
15 | },
16 | "organizeImports": {
17 | "enabled": true
18 | },
19 | "linter": {
20 | "enabled": true,
21 | "rules": {
22 | "recommended": true,
23 | "suspicious": {
24 | "noArrayIndexKey": "off"
25 | }
26 | }
27 | },
28 | "javascript": {
29 | "formatter": {
30 | "quoteStyle": "double"
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | web:
3 | build: .
4 | ports:
5 | - "8080:80"
6 | restart: unless-stopped
7 | # 如果你想挂载配置文件,可以取消下面的注释
8 | # volumes:
9 | # - ./nginx.conf:/etc/nginx/conf.d/default.conf
10 | healthcheck:
11 | test: ["CMD", "curl", "-f", "http://localhost:80"]
12 | interval: 30s
13 | timeout: 10s
14 | retries: 3
15 |
--------------------------------------------------------------------------------
/docs/chat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/docs/chat.png
--------------------------------------------------------------------------------
/docs/home.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/docs/home.png
--------------------------------------------------------------------------------
/docs/images_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/docs/images_light.png
--------------------------------------------------------------------------------
/docs/images_mobile_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/docs/images_mobile_dark.png
--------------------------------------------------------------------------------
/nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 | root /usr/share/nginx/html;
5 | index index.html;
6 | absolute_redirect off; # 禁用绝对路径重定向
7 |
8 | # 启用 gzip 压缩
9 | gzip on;
10 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
11 |
12 | # 处理 SSG 路由
13 | location / {
14 | try_files $uri $uri.html $uri/index.html =404;
15 | add_header Cache-Control "no-cache";
16 | }
17 |
18 | # 缓存静态资源
19 | location /assets {
20 | expires 1y;
21 | add_header Cache-Control "public, no-transform";
22 | }
23 |
24 | # 禁止访问 . 文件
25 | location ~ /\. {
26 | deny all;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "sideEffects": false,
4 | "type": "module",
5 | "scripts": {
6 | "dev": "react-router dev",
7 | "build": "react-router build",
8 | "start": "react-router-serve ./build/server/index.js",
9 | "typecheck": "react-router typegen && tsc",
10 | "format": "biome format --write ."
11 | },
12 | "dependencies": {
13 | "@chakra-ui/react": "^3.2.0",
14 | "@hookform/resolvers": "^3.9.1",
15 | "@react-router/fs-routes": "7.0.1",
16 | "@react-router/node": "7.0.1",
17 | "@react-router/serve": "7.0.1",
18 | "@tanstack/react-query": "^5.61.3",
19 | "blurhash": "^2.0.5",
20 | "date-fns": "^4.1.0",
21 | "drizzle-orm": "^0.36.4",
22 | "drizzle-zod": "^0.5.1",
23 | "isbot": "^5.1.17",
24 | "ky": "^1.7.2",
25 | "lucide-react": "^0.460.0",
26 | "next-themes": "^0.4.3",
27 | "react": "^18.3.1",
28 | "react-dom": "^18.3.1",
29 | "react-hook-form": "^7.53.2",
30 | "react-icons": "^5.3.0",
31 | "react-markdown": "^9.0.1",
32 | "react-router": "7.0.1",
33 | "react-textarea-autosize": "^8.5.5",
34 | "rehype-highlight": "^7.0.1",
35 | "remark-gfm": "^4.0.0",
36 | "zod": "^3.23.8"
37 | },
38 | "devDependencies": {
39 | "@biomejs/biome": "^1.9.4",
40 | "@flydotio/dockerfile": "^0.5.9",
41 | "@react-router/dev": "7.0.1",
42 | "@types/react": "^18.3.12",
43 | "@types/react-dom": "^18.3.1",
44 | "drizzle-kit": "^0.28.1",
45 | "typescript": "^5.7.2",
46 | "vite": "^5.4.11",
47 | "vite-tsconfig-paths": "^5.1.3"
48 | },
49 | "engines": {
50 | "node": ">=20.0.0"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akazwz/openchat-web/647b2bfca24df7f96ac19330df5fece728c14328/public/favicon.ico
--------------------------------------------------------------------------------
/react-router.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "@react-router/dev/config";
2 |
3 | export default {
4 | ssr: false,
5 | prerender: true,
6 | } satisfies Config;
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "**/*.ts",
4 | "**/*.tsx",
5 | "**/.server/**/*.ts",
6 | "**/.server/**/*.tsx",
7 | "**/.client/**/*.ts",
8 | "**/.client/**/*.tsx",
9 | ".react-router/types/**/*"
10 | ],
11 | "compilerOptions": {
12 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
13 | "types": ["@react-router/node", "vite/client"],
14 | "isolatedModules": true,
15 | "esModuleInterop": true,
16 | "jsx": "react-jsx",
17 | "module": "ESNext",
18 | "moduleResolution": "Bundler",
19 | "resolveJsonModule": true,
20 | "target": "ES2022",
21 | "strict": true,
22 | "allowJs": true,
23 | "skipLibCheck": true,
24 | "forceConsistentCasingInFileNames": true,
25 | "baseUrl": ".",
26 | "paths": {
27 | "~/*": ["./app/*"]
28 | },
29 | "noEmit": true,
30 | "rootDirs": [".", "./.react-router/types"],
31 | "plugins": [{ "name": "@react-router/dev" }]
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { reactRouter } from "@react-router/dev/vite";
2 | import tsconfigPaths from "vite-tsconfig-paths";
3 | import { defineConfig } from "vite";
4 |
5 | export default defineConfig({
6 | plugins: [reactRouter(), tsconfigPaths()],
7 | build: {
8 | target: "esnext",
9 | },
10 | });
11 |
--------------------------------------------------------------------------------