Initial commit
This commit is contained in:
40
.gitea/workflows/build-push.yml
Normal file
40
.gitea/workflows/build-push.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: 发布 Docker 镜像
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: 推送 Docker 镜像到 Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: 检出仓库代码
|
||||
uses: https://git.kakunet.top/actions/checkout@v4
|
||||
|
||||
- name: 登录到 Gitea Docker Hub
|
||||
uses: https://git.kakunet.top/docker/login-action@v3
|
||||
with:
|
||||
registry: git.yinlihupo.cn
|
||||
username: { secrets.DOCKER_USERNAME }
|
||||
password: { secrets.DOCKER_PASSWORD }
|
||||
|
||||
- name: 提取 Docker 元数据(标签、标记)
|
||||
id: meta
|
||||
uses: https://git.kakunet.top/docker/metadata-action@v5
|
||||
with:
|
||||
images: git.yinlihupo.cn/warmonion/intelligent-daily-report-system
|
||||
|
||||
- name: 构建并推送后端 Docker 镜像
|
||||
uses: https://git.kakunet.top/docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./dockerfile
|
||||
push: true
|
||||
tags: { steps.meta.outputs.tags }
|
||||
labels: { steps.meta.outputs.labels }
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.env
|
||||
|
||||
# prod file
|
||||
.sqlite
|
||||
.DS_Store
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
105
config.py
Normal file
105
config.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace # 导入SimpleNamespace
|
||||
from typing import Any, Dict
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class EnvConfig:
|
||||
def __init__(self):
|
||||
# 加载环境变量
|
||||
if os.path.exists(".env"):
|
||||
if load_dotenv(".env"):
|
||||
logger.info("[激活配置] .env 配置加载成功 ✅")
|
||||
else:
|
||||
# 将 exit(1) 更改为 warning,避免程序直接退出
|
||||
logger.warning(
|
||||
"[激活配置] 环境变量加载失败,请检查 .env 文件内容是否正确"
|
||||
)
|
||||
else:
|
||||
logger.warning("[激活配置] .env 文件不存在,将不加载环境变量")
|
||||
|
||||
def _parse_bool(self, value):
|
||||
"""将字符串形式的布尔值转换为布尔类型"""
|
||||
if isinstance(value, str):
|
||||
if value.lower() == "true":
|
||||
return True
|
||||
elif value.lower() == "false":
|
||||
return False
|
||||
return value
|
||||
|
||||
def __getattr__(self, name):
|
||||
value = os.getenv(name)
|
||||
if value is not None:
|
||||
return self._parse_bool(value)
|
||||
default_name = f"DEFAULT_{name}"
|
||||
default_value = os.getenv(default_name)
|
||||
if default_value is not None:
|
||||
return self._parse_bool(default_value)
|
||||
return None
|
||||
|
||||
def get(self, name, default=None):
|
||||
value = os.getenv(name)
|
||||
if value is not None:
|
||||
return self._parse_bool(value)
|
||||
return default
|
||||
|
||||
|
||||
class YamlConfig:
|
||||
def __init__(self, yaml_file: str = "config.yaml"):
|
||||
self._yaml_file = Path(yaml_file)
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self) -> None:
|
||||
if not self._yaml_file.exists():
|
||||
raise FileNotFoundError(f"[激活配置] 配置文件 {self._yaml_file} 不存在")
|
||||
|
||||
with open(self._yaml_file, "r", encoding="utf-8") as f:
|
||||
config_data = yaml.safe_load(f) or {}
|
||||
|
||||
self._set_attributes(config_data)
|
||||
logger.info("[激活配置] config.yaml 配置加载成功 ✅")
|
||||
|
||||
def _set_attributes(self, config_data: Dict[str, Any]) -> None:
|
||||
# 使用 SimpleNamespace 更好地处理嵌套配置,使其可以通过属性访问
|
||||
for key, value in config_data.items():
|
||||
if isinstance(value, dict):
|
||||
setattr(self, key, SimpleNamespace(**value))
|
||||
else:
|
||||
setattr(self, key, value)
|
||||
|
||||
# 移除 _from_dict 方法,因为 SimpleNamespace 可以直接从字典创建
|
||||
|
||||
def __repr__(self) -> str:
|
||||
attrs = []
|
||||
for key in sorted(self.__dict__.keys()):
|
||||
if not key.startswith("_"):
|
||||
value = getattr(self, key)
|
||||
attrs.append(f"{key}={repr(value)}")
|
||||
return f"Settings({', '.join(attrs)})"
|
||||
|
||||
def reload(self) -> None:
|
||||
if self._yaml_file is None:
|
||||
raise RuntimeError("无法重新加载,此实例是从字典创建的")
|
||||
self._load_settings()
|
||||
|
||||
|
||||
# 创建配置实例
|
||||
env_config = EnvConfig()
|
||||
yaml_config = None
|
||||
try:
|
||||
yaml_config = YamlConfig()
|
||||
except FileNotFoundError:
|
||||
logger.warning("[激活配置] 配置文件 config.yaml 不存在,将不加载配置")
|
||||
|
||||
|
||||
class Setting:
|
||||
def __init__(self):
|
||||
self.env = env_config
|
||||
self.config = yaml_config
|
||||
|
||||
|
||||
setting = Setting()
|
||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
call-analysis:
|
||||
image: git.yinlihupo.cn/warmonion/intelligent-daily-report-system:latest
|
||||
# 读取环境变量
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- "8000:8000"
|
||||
21
dockerfile
Normal file
21
dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM docker.mirrors.kakunet.top/library/python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装uv包管理器,使用清华源
|
||||
RUN pip3 install uv -i https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# 复制依赖文件
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# 安装依赖
|
||||
RUN uv sync -i https://mirrors.aliyun.com/pypi/simple
|
||||
|
||||
# 复制项目代码
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动命令
|
||||
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "debug"]
|
||||
2
frontend/.eslintignore
Normal file
2
frontend/.eslintignore
Normal file
@@ -0,0 +1,2 @@
|
||||
src/components/animate-ui/**
|
||||
src/components/ui/**
|
||||
26
frontend/.gitignore
vendored
Normal file
26
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env*
|
||||
opt
|
||||
.marscode
|
||||
0
frontend/README.md
Normal file
0
frontend/README.md
Normal file
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {
|
||||
"@animate-ui": "https://animate-ui.com/r/{name}.json",
|
||||
"@manifest": "https://ui.manifest.build/r/{name}.json"
|
||||
}
|
||||
}
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist", "src/components/animate-ui", "src/components/ui"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
]);
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="shadcn">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>intelligent-daily-report-system</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
109
frontend/package.json
Normal file
109
frontend/package.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"name": "hf-analysis-dash",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"generate": "npx openapi-typescript http://127.0.0.1:8000/openapi.json -o ./src/path/schema.d.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "^0.0.42",
|
||||
"@floating-ui/react": "^0.27.16",
|
||||
"@icons-pack/react-simple-icons": "^13.8.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"ahooks": "^3.9.5",
|
||||
"ai": "^5.0.98",
|
||||
"cally": "^0.8.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"harden-react-markdown": "^1.1.5",
|
||||
"i": "^0.3.7",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.544.0",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
"npm": "^11.6.2",
|
||||
"openapi-fetch": "^0.14.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"pikaday": "^1.8.2",
|
||||
"preline": "^3.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-tailwindcss-datepicker": "^2.0.0",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^3.2.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^3.15.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/daisyui": "^2.1.1",
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@iconify/tailwind4": "^1.1.0",
|
||||
"@shikijs/transformers": "^3.15.0",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"daisyui": "^5.1.25",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.4.0",
|
||||
"lucide": "^0.548.0",
|
||||
"openapi-typescript": "^7.9.1",
|
||||
"react-uselocalstoragelistener": "^1.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.44.0",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
7954
frontend/pnpm-lock.yaml
generated
Normal file
7954
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
frontend/public/logo.svg
Normal file
3
frontend/public/logo.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M416 425.6l78.4 44.8c9.6 6.4 16 16 16 27.2v91.2c0 11.2-6.4 22.4-16 27.2L416 662.4c-9.6 6.4-22.4 6.4-32 0l-78.4-44.8c-9.6-6.4-16-16-16-27.2v-91.2c0-11.2 6.4-22.4 16-27.2l78.4-44.8c9.6-8 22.4-8 32-1.6z" fill="#2F4BFF" /><path d="M643.2 267.2c-3.2-1.6-4.8-1.6-8 0l-67.2 38.4c-3.2 1.6-3.2 4.8-3.2 6.4v76.8c0 3.2 1.6 4.8 3.2 6.4l67.2 38.4c3.2 1.6 4.8 1.6 8 0l67.2-38.4c3.2-1.6 3.2-4.8 3.2-6.4v-76.8c0-3.2-1.6-4.8-3.2-6.4l-67.2-38.4z m9.6-12.8l67.2 38.4c8 4.8 12.8 12.8 12.8 20.8v76.8c0 8-4.8 16-12.8 20.8l-67.2 38.4c-8 4.8-16 4.8-24 0l-67.2-38.4c-8-4.8-12.8-12.8-12.8-20.8v-76.8c0-8 4.8-16 12.8-20.8l67.2-38.4c6.4-4.8 16-4.8 24 0zM688 691.2l-67.2 38.4v76.8l67.2 38.4 67.2-38.4v-76.8L688 691.2z m83.2 9.6c9.6 6.4 16 16 16 27.2v76.8c0 11.2-6.4 22.4-16 27.2L704 873.6c-9.6 6.4-22.4 6.4-32 0l-67.2-38.4c-9.6-6.4-16-16-16-27.2v-76.8c0-11.2 6.4-22.4 16-27.2l67.2-38.4c9.6-6.4 22.4-6.4 32 0l67.2 35.2zM176 169.6v44.8l40 22.4 40-22.4v-44.8l-40-22.4-40 22.4zM275.2 144c8 4.8 12.8 12.8 12.8 20.8v54.4c0 8-4.8 16-12.8 20.8l-48 27.2c-8 4.8-16 4.8-24 0l-48-27.2c-6.4-4.8-11.2-12.8-11.2-20.8v-54.4c0-8 4.8-16 12.8-20.8l48-27.2c8-4.8 16-4.8 24 0L275.2 144zM192 777.6l-48 27.2v54.4l48 27.2 48-27.2v-54.4l-48-27.2z m8-14.4l48 27.2c4.8 3.2 8 8 8 14.4v54.4c0 6.4-3.2 11.2-8 14.4l-48 27.2c-4.8 3.2-11.2 3.2-16 0l-48-27.2c-4.8-3.2-8-8-8-14.4v-54.4c0-6.4 3.2-11.2 8-14.4l48-27.2c4.8-3.2 11.2-3.2 16 0z" fill="#050D42" /><path d="M403.2 776l-62.4 62.4c-1.6 1.6-3.2 1.6-6.4 1.6h-88c-4.8 0-8-3.2-8-8s3.2-8 8-8h84.8l59.2-59.2v-68.8c0-4.8 3.2-8 8-8s8 3.2 8 8v64H576c4.8 0 8 3.2 8 8s-3.2 8-8 8H403.2z m-11.2-436.8l-108.8-94.4c-3.2-3.2-3.2-8-1.6-11.2 3.2-3.2 8-3.2 11.2-1.6l110.4 94.4H528c4.8 0 8 3.2 8 8s-3.2 8-8 8h-120V400c0 4.8-3.2 8-8 8s-8-3.2-8-8v-60.8zM800 728c-4.8 0-8-3.2-8-8s3.2-8 8-8h88c4.8 0 8 3.2 8 8s-3.2 8-8 8H800z m-49.6-435.2c-3.2 3.2-8 3.2-11.2 1.6-3.2-3.2-3.2-8-1.6-11.2l96-112c3.2-3.2 8-3.2 11.2-1.6 3.2 3.2 3.2 8 1.6 11.2l-96 112zM160 504c-4.8 0-8-3.2-8-8s3.2-8 8-8h112c4.8 0 8 3.2 8 8s-3.2 8-8 8h-112z m536 144c0 4.8-3.2 8-8 8s-8-3.2-8-8V544c0-4.8 3.2-8 8-8s8 3.2 8 8v104z" fill="#050D42" /></svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
9
frontend/src/App.tsx
Normal file
9
frontend/src/App.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>App</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
155
frontend/src/components/ui/alert-dialog.tsx
Normal file
155
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
51
frontend/src/components/ui/avatar.tsx
Normal file
51
frontend/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
frontend/src/components/ui/badge.tsx
Normal file
46
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
60
frontend/src/components/ui/button.tsx
Normal file
60
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
239
frontend/src/components/ui/carousel.tsx
Normal file
239
frontend/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
30
frontend/src/components/ui/checkbox.tsx
Normal file
30
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
31
frontend/src/components/ui/collapsible.tsx
Normal file
31
frontend/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
141
frontend/src/components/ui/dialog.tsx
Normal file
141
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
255
frontend/src/components/ui/dropdown-menu.tsx
Normal file
255
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
42
frontend/src/components/ui/hover-card.tsx
Normal file
42
frontend/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
193
frontend/src/components/ui/item.tsx
Normal file
193
frontend/src/components/ui/item.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn("group/item-group flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn("my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border-border",
|
||||
muted: "bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "p-4 gap-4 ",
|
||||
sm: "py-3 px-4 gap-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
"flex basis-full items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
};
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
127
frontend/src/components/ui/pagination.tsx
Normal file
127
frontend/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
29
frontend/src/components/ui/progress.tsx
Normal file
29
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
43
frontend/src/components/ui/radio-group.tsx
Normal file
43
frontend/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
56
frontend/src/components/ui/scroll-area.tsx
Normal file
56
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
185
frontend/src/components/ui/select.tsx
Normal file
185
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
26
frontend/src/components/ui/separator.tsx
Normal file
26
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
61
frontend/src/components/ui/slider.tsx
Normal file
61
frontend/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max]
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
29
frontend/src/components/ui/switch.tsx
Normal file
29
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
64
frontend/src/components/ui/tabs.tsx
Normal file
64
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
59
frontend/src/components/ui/tooltip.tsx
Normal file
59
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
28
frontend/src/hooks/use-click-anywhere.tsx
Normal file
28
frontend/src/hooks/use-click-anywhere.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
// Simple event listener hook for document click events
|
||||
function useEventListener(eventName: string, handler: (event: MouseEvent) => void) {
|
||||
const savedHandler = React.useRef(handler);
|
||||
|
||||
React.useEffect(() => { savedHandler.current = handler; }, [handler]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const eventListener = (event: Event) => savedHandler.current(event as MouseEvent);
|
||||
document.addEventListener(eventName, eventListener);
|
||||
return () => document.removeEventListener(eventName, eventListener);
|
||||
}, [eventName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that handles click events anywhere on the document
|
||||
* @param handler The function to be called when a click event is detected anywhere on the document
|
||||
*/
|
||||
export function useClickAnyWhere(handler: (event: MouseEvent) => void): void {
|
||||
useEventListener('click', event => {
|
||||
handler(event);
|
||||
});
|
||||
}
|
||||
33
frontend/src/hooks/use-controlled-state.tsx
Normal file
33
frontend/src/hooks/use-controlled-state.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
defaultValue?: T;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useControlledState<T, Rest extends any[] = []>(
|
||||
props: CommonControlledStateProps<T> & {
|
||||
onChange?: (value: T, ...args: Rest) => void;
|
||||
},
|
||||
): readonly [T, (next: T, ...args: Rest) => void] {
|
||||
const { value, defaultValue, onChange } = props;
|
||||
|
||||
const [state, setInternalState] = React.useState<T>(
|
||||
value !== undefined ? value : (defaultValue as T),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) setInternalState(value);
|
||||
}, [value]);
|
||||
|
||||
const setState = React.useCallback(
|
||||
(next: T, ...args: Rest) => {
|
||||
setInternalState(next);
|
||||
onChange?.(next, ...args);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
||||
25
frontend/src/hooks/use-is-in-view.tsx
Normal file
25
frontend/src/hooks/use-is-in-view.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import { useInView, type UseInViewOptions } from 'motion/react';
|
||||
|
||||
interface UseIsInViewOptions {
|
||||
inView?: boolean;
|
||||
inViewOnce?: boolean;
|
||||
inViewMargin?: UseInViewOptions['margin'];
|
||||
}
|
||||
|
||||
function useIsInView<T extends HTMLElement = HTMLElement>(
|
||||
ref: React.Ref<T>,
|
||||
options: UseIsInViewOptions = {},
|
||||
) {
|
||||
const { inView, inViewOnce = false, inViewMargin = '0px' } = options;
|
||||
const localRef = React.useRef<T>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as T);
|
||||
const inViewResult = useInView(localRef, {
|
||||
once: inViewOnce,
|
||||
margin: inViewMargin,
|
||||
});
|
||||
const isInView = !inView || inViewResult;
|
||||
return { ref: localRef, isInView };
|
||||
}
|
||||
|
||||
export { useIsInView, type UseIsInViewOptions };
|
||||
120
frontend/src/index.css
Normal file
120
frontend/src/index.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
36
frontend/src/lib/get-strict-context.tsx
Normal file
36
frontend/src/lib/get-strict-context.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
|
||||
function getStrictContext<T>(
|
||||
name?: string,
|
||||
): readonly [
|
||||
({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: T;
|
||||
children?: React.ReactNode;
|
||||
}) => React.JSX.Element,
|
||||
() => T,
|
||||
] {
|
||||
const Context = React.createContext<T | undefined>(undefined);
|
||||
|
||||
const Provider = ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: T;
|
||||
children?: React.ReactNode;
|
||||
}) => <Context.Provider value={value}>{children}</Context.Provider>;
|
||||
|
||||
const useSafeContext = () => {
|
||||
const ctx = React.useContext(Context);
|
||||
if (ctx === undefined) {
|
||||
throw new Error(`useContext must be used within ${name ?? 'a Provider'}`);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
return [Provider, useSafeContext] as const;
|
||||
}
|
||||
|
||||
export { getStrictContext };
|
||||
39
frontend/src/lib/hooks/useURL.tsx
Normal file
39
frontend/src/lib/hooks/useURL.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from "react";
|
||||
function useURL() {
|
||||
const [params, setParams] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const handleUrlChange = () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const newParams = {};
|
||||
searchParams.forEach((value, key) => {
|
||||
(newParams as Record<string, string>)[key] = value;
|
||||
});
|
||||
setParams(newParams);
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handleUrlChange);
|
||||
handleUrlChange(); // 初始解析
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleUrlChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateParams = (newParams: Record<string, string>) => {
|
||||
const url = new URL(window.location.origin);
|
||||
Object.entries(newParams).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
});
|
||||
window.history.pushState({}, "", url);
|
||||
setParams(newParams);
|
||||
};
|
||||
|
||||
return { params, setParams: updateParams };
|
||||
}
|
||||
|
||||
export default useURL;
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
<div className="z-[99999]">
|
||||
<Toaster />
|
||||
</div>
|
||||
</StrictMode>,
|
||||
);
|
||||
55
frontend/src/path/client.ts
Normal file
55
frontend/src/path/client.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import createFetchClient from "openapi-fetch";
|
||||
import type { Middleware } from "openapi-fetch";
|
||||
import type { paths } from "./schema";
|
||||
import createClient from "openapi-react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
const createInterceptor = () => {
|
||||
const intercept: Middleware = {
|
||||
async onResponse({ response }) {
|
||||
const responseClone = response.clone();
|
||||
|
||||
const { status } = responseClone;
|
||||
if (status === 0) {
|
||||
toast.error("网络连接失败,请检查网络连接");
|
||||
return response;
|
||||
}
|
||||
if (status >= 400) {
|
||||
const body = await responseClone.json();
|
||||
if (body?.detail) {
|
||||
toast.error(body?.detail);
|
||||
}
|
||||
if (status === 408) {
|
||||
toast.error("请求超时,请稍后重试");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
if (status >= 200 && status < 300) {
|
||||
const body = await responseClone.json();
|
||||
if (body?.message) {
|
||||
toast.success(body?.message);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async onRequest({ request }) {
|
||||
const user = JSON.parse(localStorage.getItem("userInfo") || "{}");
|
||||
if (user?.token) {
|
||||
request.headers.set("Authorization", `Bearer ${user.token}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
return intercept;
|
||||
};
|
||||
|
||||
const client = createFetchClient<paths>({
|
||||
baseUrl: import.meta.env.VITE_BASE_URL || "/",
|
||||
});
|
||||
|
||||
// 注册中间件
|
||||
client.use(createInterceptor());
|
||||
const queryClient = createClient(client);
|
||||
|
||||
export default queryClient;
|
||||
export { client };
|
||||
418
frontend/src/path/schema.d.ts
vendored
Normal file
418
frontend/src/path/schema.d.ts
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 获取API统计信息
|
||||
* @description 获取API统计信息
|
||||
*/
|
||||
get: operations["get_stats_api_stats_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/profiler": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Dashboard
|
||||
* @description Serve the profiler dashboard.
|
||||
*/
|
||||
get: operations["dashboard_profiler_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/profiler/api/profiles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Profiles
|
||||
* @description Return recent profile data as JSON.
|
||||
*/
|
||||
get: operations["get_profiles_profiler_api_profiles_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/profiler/api/dashboard-data": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Dashboard Data
|
||||
* @description Return pre-calculated data for the dashboard.
|
||||
*/
|
||||
get: operations["get_dashboard_data_profiler_api_dashboard_data_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/profiler/api/profile/{profile_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Profile
|
||||
* @description Return a specific profile by ID.
|
||||
*/
|
||||
get: operations["get_profile_profiler_api_profile__profile_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Run Agent */
|
||||
post: operations["run_agent__post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/shaoshupai": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Shaoshupai */
|
||||
get: operations["get_shaoshupai_shaoshupai_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** ShaoshupaiArticleModel */
|
||||
ShaoshupaiArticleModel: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Title */
|
||||
title: string;
|
||||
/**
|
||||
* Banner
|
||||
* @default
|
||||
*/
|
||||
banner: string;
|
||||
/**
|
||||
* Summary
|
||||
* @default
|
||||
*/
|
||||
summary: string;
|
||||
/**
|
||||
* Comment Count
|
||||
* @default 0
|
||||
*/
|
||||
comment_count: number;
|
||||
/**
|
||||
* Like Count
|
||||
* @default 0
|
||||
*/
|
||||
like_count: number;
|
||||
/**
|
||||
* View Count
|
||||
* @default 0
|
||||
*/
|
||||
view_count: number;
|
||||
/**
|
||||
* Free
|
||||
* @default false
|
||||
*/
|
||||
free: boolean;
|
||||
/**
|
||||
* Post Type
|
||||
* @default 0
|
||||
*/
|
||||
post_type: number;
|
||||
/**
|
||||
* Important
|
||||
* @default 0
|
||||
*/
|
||||
important: number;
|
||||
/**
|
||||
* Released Time
|
||||
* @default 0
|
||||
*/
|
||||
released_time: number;
|
||||
/**
|
||||
* Morning Paper Title
|
||||
* @default []
|
||||
*/
|
||||
morning_paper_title: unknown[];
|
||||
/**
|
||||
* Advertisement Url
|
||||
* @default
|
||||
*/
|
||||
advertisement_url: string;
|
||||
/**
|
||||
* Series
|
||||
* @default []
|
||||
*/
|
||||
series: unknown[];
|
||||
};
|
||||
/** ShaoshupaiArticlesModel */
|
||||
ShaoshupaiArticlesModel: {
|
||||
/** Articles */
|
||||
articles: components["schemas"]["ShaoshupaiArticleModel"][];
|
||||
};
|
||||
/** ShaoshupaiResponseModel */
|
||||
ShaoshupaiResponseModel: {
|
||||
data: components["schemas"]["ShaoshupaiArticlesModel"];
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
loc: (string | number)[];
|
||||
/** Message */
|
||||
msg: string;
|
||||
/** Error Type */
|
||||
type: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
get_stats_api_stats_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
ip?: string;
|
||||
path?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
dashboard_profiler_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_profiles_profiler_api_profiles_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_dashboard_data_profiler_api_dashboard_data_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_profile_profiler_api_profile__profile_id__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
profile_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
run_agent__post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_shaoshupai_shaoshupai_get: {
|
||||
parameters: {
|
||||
query: {
|
||||
search_term: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ShaoshupaiResponseModel"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
3
frontend/src/style/catppuccinTheme.frappe.ts
Normal file
3
frontend/src/style/catppuccinTheme.frappe.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
||||
|
||||
export default createCatppuccinPlugin("frappe", "lavender");
|
||||
3
frontend/src/style/catppuccinTheme.latte.ts
Normal file
3
frontend/src/style/catppuccinTheme.latte.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createCatppuccinPlugin } from '@catppuccin/daisyui'
|
||||
|
||||
export default createCatppuccinPlugin('latte')
|
||||
12
frontend/src/style/catppuccinTheme.macchiato.ts
Normal file
12
frontend/src/style/catppuccinTheme.macchiato.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
||||
|
||||
export default createCatppuccinPlugin("macchiato", {
|
||||
primary: "lavender",
|
||||
"primary-content": "mantle",
|
||||
secondary: "surface0",
|
||||
"secondary-content": "text",
|
||||
accent: "rosewater",
|
||||
"accent-content": "mantle",
|
||||
neutral: "overlay1",
|
||||
"neutral-content": "mantle",
|
||||
});
|
||||
18
frontend/src/style/catppuccinTheme.mocha.ts
Normal file
18
frontend/src/style/catppuccinTheme.mocha.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
||||
|
||||
export default createCatppuccinPlugin(
|
||||
"mocha",
|
||||
{
|
||||
"--radius-selector": "0.25rem",
|
||||
"--radius-field": "0.5rem",
|
||||
"--radius-box": "0.5rem",
|
||||
"--size-field": "0.25rem",
|
||||
"--size-selector": "0.25rem",
|
||||
"--border": "1px",
|
||||
"--depth": true,
|
||||
"--noise": false,
|
||||
},
|
||||
{
|
||||
prefersdark: true,
|
||||
},
|
||||
);
|
||||
33
frontend/tsconfig.app.json
Normal file
33
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/components/animate-ui", "src/components/ui"]
|
||||
}
|
||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["src/components/animate-ui", "src/components/ui"]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": [],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
frontend/vite.config.ts
Normal file
25
frontend/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ['**/node_modules/**', '**/dist/**' , '**/opt/**'],
|
||||
},
|
||||
proxy: {
|
||||
"/oauth2": {
|
||||
target: "http://127.0.0.1:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
3
handler/__init__.py
Normal file
3
handler/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .exception import install
|
||||
|
||||
__all__ = ["install"]
|
||||
31
handler/exception.py
Normal file
31
handler/exception.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import http
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from pydantic import ValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from uvicorn.server import logger
|
||||
|
||||
exceptions = [Exception, HTTPException, ValidationError]
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, e: Exception):
|
||||
logger.error(f"[异常处理] 发生异常: {e}")
|
||||
status_code = http.HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
detail = str(e)
|
||||
message = "发生了一些错误"
|
||||
if isinstance(e, HTTPException):
|
||||
status_code = e.status_code
|
||||
message = e.detail
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={"detail": detail, "message": message},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def install(app: FastAPI):
|
||||
logger.info("[异常处理] 开始加载异常处理器")
|
||||
for exception in exceptions:
|
||||
app.add_exception_handler(exception, general_exception_handler)
|
||||
logger.info("[异常处理] 异常处理器加载完成")
|
||||
42
lifespan.py
Normal file
42
lifespan.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Callable
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from plugin import PluginManager
|
||||
from uvicorn.server import logger
|
||||
|
||||
|
||||
def active_config():
|
||||
logger.info("[激活配置] 加载配置 ⚙️")
|
||||
from config import setting # noqa
|
||||
|
||||
|
||||
def import_router(app: FastAPI):
|
||||
logger.info("[导入路由] 开始导入路由 🛣️")
|
||||
from router import router
|
||||
|
||||
app.include_router(router)
|
||||
logger.info("[导入路由] 路由导入完成 ✅")
|
||||
|
||||
|
||||
def install_plugins_after_started(app: FastAPI):
|
||||
logger.info("[安装插件] 开始安装插件 📦")
|
||||
from plugin import SpaProxy
|
||||
|
||||
plugin_manager = PluginManager(app)
|
||||
# 安装插件
|
||||
plugin_manager.register_plugin(SpaProxy(app, dist="frontend/dist"))
|
||||
|
||||
logger.info("[安装插件] 插件安装完成 ✅")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI, func: Callable = lambda: None):
|
||||
logger.info("[生命周期] 应用启动 🚀")
|
||||
active_config()
|
||||
import_router(app)
|
||||
install_plugins_after_started(app)
|
||||
func()
|
||||
yield
|
||||
logger.info("[生命周期] 应用关闭 🔧✅")
|
||||
23
main.py
Normal file
23
main.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from handler.exception import install as exception_install
|
||||
from lifespan import lifespan
|
||||
from plugin import CORSMiddleware, Monitor, PluginManager, Profiler
|
||||
from utils.start import start
|
||||
|
||||
app: FastAPI = start(lambda: FastAPI(lifespan=lifespan))
|
||||
# 添加 GZip 中间件
|
||||
app.add_middleware(
|
||||
GZipMiddleware,
|
||||
minimum_size=1000, # 仅压缩大于 1000 字节的响应
|
||||
compresslevel=5, # 压缩级别,范围为 1-9,默认值为 9
|
||||
)
|
||||
# 安装异常处理中间件
|
||||
exception_install(app)
|
||||
|
||||
# 安装需要启动后才能安装插件
|
||||
plugin_manager = PluginManager(app)
|
||||
plugin_manager.register_plugin(CORSMiddleware(app))
|
||||
plugin_manager.register_plugin(Monitor(app))
|
||||
plugin_manager.register_plugin(Profiler(app))
|
||||
17
plugin/__init__.py
Normal file
17
plugin/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from .base import PluginManager
|
||||
from .cors import CORSMiddleware
|
||||
from .monitor import Monitor
|
||||
from .profiler import Profiler
|
||||
from .spa import SpaProxy
|
||||
from .user import AuthUser, UserManager, UserSource
|
||||
|
||||
__all__ = [
|
||||
"PluginManager",
|
||||
"Monitor",
|
||||
"SpaProxy",
|
||||
"CORSMiddleware",
|
||||
"AuthUser",
|
||||
"UserManager",
|
||||
"UserSource",
|
||||
"Profiler",
|
||||
]
|
||||
34
plugin/base.py
Normal file
34
plugin/base.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from uvicorn.server import logger
|
||||
|
||||
|
||||
class Plugin:
|
||||
def __init__(
|
||||
self, app: FastAPI, name="Unnamed Plugin", version="1.0.0", *args, **kwargs
|
||||
):
|
||||
self.app = app
|
||||
self.name = name
|
||||
self.version = version
|
||||
|
||||
def install(self):
|
||||
pass
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, app: FastAPI):
|
||||
self.app = app
|
||||
|
||||
def register_plugin(self, plugin: Plugin):
|
||||
plugin_name = getattr(plugin, "name", "Unnamed Plugin")
|
||||
plugin_version = getattr(plugin, "version", "Unknown Version")
|
||||
logger.info(f"[插件] Registering plugin: [{plugin_name} {plugin_version}] ")
|
||||
try:
|
||||
plugin.install()
|
||||
logger.info(
|
||||
f"[插件] Plugin [{plugin_name} {plugin_version}] installed successfully ✅"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[插件] Failed to install plugin [{plugin_name} {plugin_version}]: {e} ❌"
|
||||
)
|
||||
3
plugin/cors/__init__.py
Normal file
3
plugin/cors/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cors import CorsPlugin as CORSMiddleware
|
||||
|
||||
__all__ = ["CORSMiddleware"]
|
||||
23
plugin/cors/cors.py
Normal file
23
plugin/cors/cors.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from plugin.base import Plugin
|
||||
|
||||
|
||||
class CorsPlugin(Plugin):
|
||||
def __init__(self, app: FastAPI):
|
||||
self.name = "CorsPlugin"
|
||||
self.description = "Enable Cross-Origin Resource Sharing (CORS)"
|
||||
self.version = "1.0.0"
|
||||
self.app = app
|
||||
|
||||
def install(self):
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# 添加CORS中间件解决跨域问题
|
||||
self.app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
3
plugin/monitor/__init__.py
Normal file
3
plugin/monitor/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .middleware import Monitor
|
||||
|
||||
__all__ = ["Monitor"]
|
||||
267
plugin/monitor/middleware.py
Normal file
267
plugin/monitor/middleware.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from plugin.base import Plugin
|
||||
from uvicorn.server import logger
|
||||
|
||||
|
||||
class APIMonitorMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(
|
||||
self,
|
||||
app: FastAPI,
|
||||
*,
|
||||
entity: object = None,
|
||||
exclude_paths: Optional[List[str]] = None,
|
||||
rate_limit_window: int = 60,
|
||||
):
|
||||
super().__init__(app)
|
||||
self.exclude_paths = exclude_paths or []
|
||||
self.rate_limit_window = rate_limit_window
|
||||
|
||||
# 用于存储请求频率数据
|
||||
self.request_counts = defaultdict(int)
|
||||
self.request_timestamps = defaultdict(list)
|
||||
|
||||
# 存储中间件实例以便后续访问
|
||||
# app.state.api_monitor = self
|
||||
entity.middleware = self
|
||||
|
||||
# 启动后台清理任务
|
||||
self.cleanup_task = asyncio.create_task(self._cleanup_old_records())
|
||||
|
||||
async def _cleanup_old_records(self):
|
||||
"""定期清理过期的请求记录"""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self.rate_limit_window * 2)
|
||||
current_time = time.time()
|
||||
keys_to_remove = []
|
||||
|
||||
for key in list(self.request_timestamps.keys()):
|
||||
# 移除超过时间窗口的时间戳
|
||||
self.request_timestamps[key] = [
|
||||
ts
|
||||
for ts in self.request_timestamps[key]
|
||||
if current_time - ts < self.rate_limit_window
|
||||
]
|
||||
if not self.request_timestamps[key]:
|
||||
keys_to_remove.append(key)
|
||||
|
||||
for key in keys_to_remove:
|
||||
del self.request_timestamps[key]
|
||||
if key in self.request_counts:
|
||||
del self.request_counts[key]
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# 任务被取消时正常退出
|
||||
pass
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# 检查是否排除该路径
|
||||
if any(request.url.path.startswith(path) for path in self.exclude_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# 记录开始时间
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
status_code = response.status_code
|
||||
# response.headers["X-Process-Time"] = str(process_time)
|
||||
error = None
|
||||
except Exception as e:
|
||||
process_time = time.time() - start_time
|
||||
response = None
|
||||
status_code = 500
|
||||
error = str(e)
|
||||
# 重新抛出异常以便FastAPI处理
|
||||
raise
|
||||
|
||||
finally:
|
||||
# 无论是否异常都记录日志
|
||||
# 更新请求频率统计
|
||||
await self._update_request_stats(request, status_code)
|
||||
|
||||
# 记录请求信息
|
||||
await self._log_request(
|
||||
request, status_code, start_time, process_time, error
|
||||
)
|
||||
|
||||
return response if response else Response(status_code=status_code)
|
||||
|
||||
async def _update_request_stats(self, request: Request, status_code: int):
|
||||
"""更新请求频率统计"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
endpoint_key = f"{client_ip}:{request.method}:{request.url.path}"
|
||||
|
||||
current_time = time.time()
|
||||
self.request_timestamps[endpoint_key].append(current_time)
|
||||
|
||||
# 计算当前时间窗口内的请求次数
|
||||
window_start = current_time - self.rate_limit_window
|
||||
self.request_timestamps[endpoint_key] = [
|
||||
ts for ts in self.request_timestamps[endpoint_key] if ts >= window_start
|
||||
]
|
||||
self.request_counts[endpoint_key] = len(self.request_timestamps[endpoint_key])
|
||||
|
||||
async def _log_request(
|
||||
self,
|
||||
request: Request,
|
||||
status_code: int,
|
||||
start_time: float,
|
||||
process_time: float = 0,
|
||||
error: Optional[str] = None,
|
||||
):
|
||||
"""记录请求日志"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
referer = request.headers.get("referer", "")
|
||||
|
||||
log_data = {
|
||||
"timestamp": datetime.fromtimestamp(start_time).isoformat(),
|
||||
"client_ip": client_ip,
|
||||
"method": request.method,
|
||||
"url": str(request.url),
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
# "body": await request.json(),
|
||||
"status_code": status_code,
|
||||
"process_time_ms": round(process_time * 1000, 2),
|
||||
"user_agent": user_agent[:200], # 限制长度
|
||||
"referer": referer[:200],
|
||||
"content_type": request.headers.get("content-type", ""),
|
||||
"content_length": int(request.headers.get("content-length", 0)),
|
||||
"error": error,
|
||||
}
|
||||
|
||||
# 拼接为一条字符串
|
||||
log_entry = f"耗时:{log_data['process_time_ms']}ms,错误:{error}, 请求参数: {str(log_data['query_params'])}, 请求url: {log_data['url']} "
|
||||
|
||||
# 根据状态码决定日志级别
|
||||
if status_code >= 500:
|
||||
logger.error(log_entry)
|
||||
elif status_code >= 400:
|
||||
logger.error(log_entry)
|
||||
elif status_code >= 300:
|
||||
logger.info(log_entry)
|
||||
elif status_code >= 200:
|
||||
logger.debug(log_entry)
|
||||
|
||||
def get_request_stats(
|
||||
self, ip: Optional[str] = None, path: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""获取请求统计信息"""
|
||||
stats = {}
|
||||
current_time = time.time()
|
||||
|
||||
for key in list(self.request_counts.keys()):
|
||||
parts = key.split(":", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
client_ip, method, endpoint_path = parts
|
||||
|
||||
if ip and client_ip != ip:
|
||||
continue
|
||||
if path and endpoint_path != path:
|
||||
continue
|
||||
|
||||
# 计算各种统计指标
|
||||
timestamps = self.request_timestamps.get(key, [])
|
||||
recent_requests = [
|
||||
ts for ts in timestamps if current_time - ts < self.rate_limit_window
|
||||
]
|
||||
|
||||
stats[key] = {
|
||||
"total_requests": len(timestamps),
|
||||
"current_window_requests": len(recent_requests),
|
||||
"requests_per_minute": len(recent_requests)
|
||||
* (60 / self.rate_limit_window),
|
||||
"last_request_time": datetime.fromtimestamp(max(timestamps)).isoformat()
|
||||
if timestamps
|
||||
else None,
|
||||
"method": method,
|
||||
"endpoint": endpoint_path,
|
||||
"client_ip": client_ip,
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def get_summary_stats(self) -> Dict[str, Any]:
|
||||
"""获取汇总统计信息"""
|
||||
total_requests = sum(
|
||||
len(timestamps) for timestamps in self.request_timestamps.values()
|
||||
)
|
||||
unique_clients = len(
|
||||
set(key.split(":")[0] for key in self.request_timestamps.keys())
|
||||
)
|
||||
unique_endpoints = len(
|
||||
set(
|
||||
key.split(":")[2]
|
||||
for key in self.request_timestamps.keys()
|
||||
if len(key.split(":")) >= 3
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_requests": total_requests,
|
||||
"unique_clients": unique_clients,
|
||||
"unique_endpoints": unique_endpoints,
|
||||
"monitoring_since": datetime.now().isoformat(),
|
||||
"time_window_seconds": self.rate_limit_window,
|
||||
}
|
||||
|
||||
|
||||
class Monitor(Plugin):
|
||||
def __init__(
|
||||
self, app: FastAPI, exclude_paths: list = None, rate_limit_window: int = 60
|
||||
):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
app (FastAPI): _description_
|
||||
exclude_paths (list, optional): _description_. Defaults to None.
|
||||
rate_limit_window (int, optional): _description_. Defaults to 60.
|
||||
"""
|
||||
logger.info("[监控插件] 加载监控插件 🔧")
|
||||
self.app = app
|
||||
self.exclude_paths = exclude_paths
|
||||
self.rate_limit_window = rate_limit_window
|
||||
self.middleware = None
|
||||
self.name = "Monitor"
|
||||
self.version = "1.0.0"
|
||||
|
||||
async def get_stats(self, ip: str = None, path: str = None):
|
||||
"""获取API统计信息"""
|
||||
if self.middleware:
|
||||
return {
|
||||
"summary": self.middleware.get_summary_stats(),
|
||||
"detailed_stats": self.middleware.get_request_stats(ip, path),
|
||||
}
|
||||
return {"error": "Monitor middleware not initialized"}
|
||||
|
||||
def install(self):
|
||||
self.app.add_middleware(
|
||||
APIMonitorMiddleware,
|
||||
entity=self,
|
||||
exclude_paths=self.exclude_paths,
|
||||
rate_limit_window=self.rate_limit_window,
|
||||
)
|
||||
|
||||
self.app.add_api_route(
|
||||
path="/api/stats",
|
||||
endpoint=self.get_stats,
|
||||
methods=["GET"],
|
||||
summary="获取API统计信息",
|
||||
description="获取API统计信息",
|
||||
)
|
||||
|
||||
logger.info("[监控插件] 监控插件已加载 ✅")
|
||||
3
plugin/profiler/__init__.py
Normal file
3
plugin/profiler/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .profiler import Profiler
|
||||
|
||||
__all__ = ["Profiler"]
|
||||
15
plugin/profiler/profiler.py
Normal file
15
plugin/profiler/profiler.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi_profiler import Profiler as FastapiProfilerMiddleware
|
||||
|
||||
from plugin.base import Plugin
|
||||
|
||||
|
||||
class Profiler(Plugin):
|
||||
def __init__(self, app: FastAPI, dashboard_path: str = "/profiler"):
|
||||
self.app = app
|
||||
self.dashboard_path = dashboard_path
|
||||
self.name = "Profiler"
|
||||
self.version = "1.0.0"
|
||||
|
||||
def install(self):
|
||||
FastapiProfilerMiddleware(self.app, self.dashboard_path)
|
||||
3
plugin/spa/__init__.py
Normal file
3
plugin/spa/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .spa_proxy import SpaProxy
|
||||
|
||||
__all__ = ["SpaProxy"]
|
||||
50
plugin/spa/spa_proxy.py
Normal file
50
plugin/spa/spa_proxy.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from plugin.base import Plugin
|
||||
from uvicorn.server import logger
|
||||
|
||||
|
||||
class SpaProxy(Plugin):
|
||||
def __init__(self, app: FastAPI, dist: str = "dist"):
|
||||
self.app = app
|
||||
self.dist = dist
|
||||
self.name = "SPA Proxy"
|
||||
self.version = "1.0.0"
|
||||
|
||||
def install(self):
|
||||
# --- [新增] 挂载前端静态文件 --
|
||||
frontend_dir = os.path.join(os.getcwd(), self.dist)
|
||||
if not os.path.isdir(frontend_dir):
|
||||
return
|
||||
|
||||
# 1. 挂载静态资源(如 /assets/...)
|
||||
assets_dir = os.path.join(frontend_dir, "assets")
|
||||
if os.path.isdir(assets_dir):
|
||||
self.app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
# 2. 【关键】兜底路由:处理所有未被 API 匹配的路径(支持前端路由如 /analysis)
|
||||
@self.app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
# 如果请求的是 API 路径,不应走到这里(因为 API 路由已先注册)
|
||||
index_path = os.path.join(frontend_dir, "index.html")
|
||||
if os.path.isfile(index_path):
|
||||
return FileResponse(index_path)
|
||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||
|
||||
# 3. 显式处理根路径(可选,但更清晰)
|
||||
@self.app.get("/")
|
||||
async def root():
|
||||
return await serve_frontend("")
|
||||
|
||||
# self.app.add_api_route(
|
||||
# "/{full_path:path}",
|
||||
# serve_frontend,
|
||||
# methods=["GET"],
|
||||
# response_class=FileResponse,
|
||||
# )
|
||||
|
||||
logger.info(f"前端静态文件挂载: {frontend_dir} ✅")
|
||||
3
plugin/user/__init__.py
Normal file
3
plugin/user/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .user_manager import AuthUser, UserManager, UserSource
|
||||
|
||||
__all__ = ["AuthUser", "UserManager", "UserSource"]
|
||||
190
plugin/user/user_manager.py
Normal file
190
plugin/user/user_manager.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import datetime
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Generic, TypedDict, TypeVar
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import List
|
||||
|
||||
ID_TYPE = str | int
|
||||
|
||||
|
||||
class AuthUser(BaseModel):
|
||||
id: ID_TYPE
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if "id" not in kwargs:
|
||||
kwargs["id"] = str(uuid.uuid4())
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Paginate(TypedDict, Generic[T]):
|
||||
items: list[T]
|
||||
total: int
|
||||
pages: int
|
||||
current_page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class UserSource(Generic[T]):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._data: dict[ID_TYPE, T] = dict[ID_TYPE, T](*args, **kwargs)
|
||||
|
||||
def __getitem__(self, key: ID_TYPE) -> T:
|
||||
return self._data[key]
|
||||
|
||||
def __setitem__(self, key: ID_TYPE, value: T) -> None:
|
||||
self._data[key] = value
|
||||
|
||||
def __delitem__(self, key: ID_TYPE) -> None:
|
||||
del self._data[key]
|
||||
|
||||
def __contains__(self, key: ID_TYPE) -> bool:
|
||||
return key in self._data
|
||||
|
||||
def keys(self) -> List[ID_TYPE]:
|
||||
return list(self._data.keys())
|
||||
|
||||
def get(self, key: ID_TYPE, default: T | None = None) -> T | None:
|
||||
return self._data.get(key, default)
|
||||
|
||||
def paginate(self, page: int = 1, per_page: int = 10) -> Paginate[T]:
|
||||
"""分页获取用户数据
|
||||
|
||||
Args:
|
||||
page: 页码,从1开始
|
||||
per_page: 每页数量
|
||||
|
||||
Returns:
|
||||
包含分页信息的字典,包括:
|
||||
- items: 当前页的数据列表
|
||||
- total: 总数据量
|
||||
- pages: 总页数
|
||||
- current_page: 当前页码
|
||||
- per_page: 每页数量
|
||||
"""
|
||||
total = len(self._data)
|
||||
pages = (total + per_page - 1) // per_page if per_page > 0 else 0
|
||||
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page > pages and pages > 0:
|
||||
page = pages
|
||||
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
|
||||
items = list(self._data.values())[start:end]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"pages": pages,
|
||||
"current_page": page,
|
||||
"per_page": per_page,
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"UserSource({self._data})"
|
||||
|
||||
|
||||
TUser = TypeVar("TUser", bound=AuthUser)
|
||||
|
||||
|
||||
class UserManager(Generic[TUser]):
|
||||
users: UserSource[TUser] = UserSource()
|
||||
secret_key: str = secrets.token_urlsafe(32)
|
||||
expiration_time: int = 1440
|
||||
algorithm: str = "HS256"
|
||||
token_type: str = "Bearer"
|
||||
auth_header_field: str = "Authorization"
|
||||
|
||||
def get_user(self, user_id: ID_TYPE) -> TUser | None:
|
||||
return self.users.get(user_id, None)
|
||||
|
||||
def add_user(self, user: TUser) -> bool:
|
||||
if user.id in self.users.keys():
|
||||
return False
|
||||
self.users[user.id] = user
|
||||
return True
|
||||
|
||||
def delete_user(self, user_id: ID_TYPE) -> bool:
|
||||
if user_id not in self.users:
|
||||
return False
|
||||
del self.users[user_id]
|
||||
return True
|
||||
|
||||
def update_user(self, user: TUser) -> bool:
|
||||
if user.id not in self.users:
|
||||
return False
|
||||
self.users[user.id] = user
|
||||
return True
|
||||
|
||||
def create_token(self, user: TUser) -> str:
|
||||
payload = {
|
||||
**user.model_dump(),
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(minutes=self.expiration_time),
|
||||
}
|
||||
token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
|
||||
return token
|
||||
|
||||
def paginate_users(self, page: int = 1, per_page: int = 10) -> Paginate[TUser]:
|
||||
"""分页查询用户
|
||||
|
||||
Args:
|
||||
page: 页码,从1开始
|
||||
per_page: 每页数量
|
||||
|
||||
Returns:
|
||||
包含分页信息的字典
|
||||
"""
|
||||
return self.users.paginate(page=page, per_page=per_page)
|
||||
|
||||
def verify_token(self, token: str) -> TUser | None:
|
||||
import jwt
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
user_id = payload.get("id")
|
||||
if user_id is None:
|
||||
return None
|
||||
return self.get_user(user_id)
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise jwt.ExpiredSignatureError
|
||||
except jwt.InvalidTokenError:
|
||||
raise jwt.InvalidTokenError
|
||||
|
||||
def refresh_token(self, token: str) -> str | None:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm])
|
||||
user_id = payload.get("id")
|
||||
if user_id is None:
|
||||
return None
|
||||
user = self.get_user(user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return self.create_token(user)
|
||||
|
||||
def fastapi_get_user(self, request: Request) -> TUser | None:
|
||||
"""FastAPI dependency to get current user from token"""
|
||||
auth_header = request.headers.get(self.auth_header_field)
|
||||
if not auth_header:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
parts = auth_header.split()
|
||||
if len(parts) != 2 or parts[0] != self.token_type:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
token = parts[1]
|
||||
try:
|
||||
user = self.verify_token(token)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token {e}")
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
51
pyproject.toml
Normal file
51
pyproject.toml
Normal file
@@ -0,0 +1,51 @@
|
||||
[project]
|
||||
name = "intelligent-daily-report-system"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{ name = "WuJiaLiang"},
|
||||
{ name = "Tordor", email = "3262978839@qq.com" },
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"casbin>=1.43.0",
|
||||
"fastapi[standard]>=0.116.1",
|
||||
"fastapi-utils>=0.8.0",
|
||||
"motor>=3.7.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"pyminio>=0.3.1",
|
||||
"pytest>=8.4.1",
|
||||
"python-dotenv>=1.1.1",
|
||||
"sqlmodel>=0.0.24",
|
||||
"uvicorn>=0.35.0",
|
||||
"apscheduler>=3.11.0",
|
||||
"pickledb>=1.3.2",
|
||||
"aiocache>=0.12.3",
|
||||
"pyfiglet>=1.0.4",
|
||||
"toml>=0.10.2",
|
||||
"pinyin>=0.4.0",
|
||||
"granian>=2.5.6",
|
||||
"loguru>=0.7.3",
|
||||
"redis<6.0.1",
|
||||
"pymysql>=1.1.2",
|
||||
"fastapi-oauth20>=0.0.2",
|
||||
"fastapi-profiler-lite>=0.3.2",
|
||||
"taskiq>=0.11.20",
|
||||
"taskiq-redis>=1.1.2",
|
||||
"python-multipart>=0.0.20",
|
||||
"pydantic-ai>=0.0.2",
|
||||
"pydantic-ai-slim[ag-ui,openai,openrouter]>=1.22.0",
|
||||
"pydantic>=2.12.4",
|
||||
"griffe>=1.15.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"isort>=6.0.1",
|
||||
"ruff>=0.14.2",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
url = "http://mirrors.aliyun.com/pypi/simple"
|
||||
default = true
|
||||
48
router/__init__.py
Normal file
48
router/__init__.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from uvicorn.server import logger
|
||||
|
||||
# 定义一个 APIRouter 实例
|
||||
router = APIRouter()
|
||||
|
||||
# 初始化 __all__ 列表,用于声明模块公开的对象
|
||||
__all__ = []
|
||||
|
||||
# 获取当前文件所在目录
|
||||
current_dir = Path(__file__).parent
|
||||
|
||||
# 定义可能的路由变量名列表
|
||||
ROUTER_VARIABLE_NAMES = ["router", "routes", "api_router"]
|
||||
|
||||
# 遍历当前目录下的所有 .py 文件
|
||||
for module_info in pkgutil.iter_modules([str(current_dir)]):
|
||||
if module_info.name != Path(__file__).stem: # 排除当前文件
|
||||
module = importlib.import_module(f".{module_info.name}", package=__package__)
|
||||
found_router = False
|
||||
# 遍历可能的路由变量名
|
||||
for var_name in ROUTER_VARIABLE_NAMES:
|
||||
if hasattr(module, var_name):
|
||||
module_router: APIRouter = getattr(module, var_name)
|
||||
api_number = len(module_router.routes)
|
||||
logger.info(
|
||||
f"[导入路由] 已导入模块: {module_info.name}.{var_name} 共计{api_number}个接口 ✅"
|
||||
)
|
||||
# 自动创建别名,使用模块名和变量名组合作为别名
|
||||
alias_name = f"{module_info.name}_{var_name}"
|
||||
globals()[alias_name] = module_router
|
||||
# 包含模块的 router 到主 router 中
|
||||
router.include_router(
|
||||
router=module_router,
|
||||
tags=[f"{alias_name} api"],
|
||||
)
|
||||
# 将别名添加到 __all__ 列表中
|
||||
__all__.append(alias_name)
|
||||
found_router = True
|
||||
if not found_router:
|
||||
logger.error(
|
||||
f"[导入路由] 未导入模块:{module_info.name},未找到以下任一变量: {', '.join(ROUTER_VARIABLE_NAMES)} "
|
||||
)
|
||||
9
router/test.py
Normal file
9
router/test.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def hello():
|
||||
return {
|
||||
"hello" : "world"
|
||||
}
|
||||
93
utils/start.py
Normal file
93
utils/start.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import re
|
||||
|
||||
import pinyin
|
||||
import pyfiglet
|
||||
import toml
|
||||
from fastapi import FastAPI
|
||||
from pydantic.types import Callable
|
||||
|
||||
|
||||
def extract_brand_name(input_str):
|
||||
"""
|
||||
提取品牌名称,规则如下:
|
||||
- 中文:返回拼音首字母(大写)
|
||||
- 英文:根据单词数量决定,少量单词返回大驼峰,大量单词返回纯大写
|
||||
- 支持多种英文命名法(驼峰、中划线、下划线等)的解析
|
||||
|
||||
参数:
|
||||
input_str: 输入的字符串(中英文均可)
|
||||
返回:
|
||||
处理后的品牌名称字符串
|
||||
"""
|
||||
# 判断是否为中文(包含中文字符)
|
||||
if re.search(r"[\u4e00-\u9fff]", input_str):
|
||||
# 提取中文并转换为拼音首字母
|
||||
chinese_chars = re.findall(r"[\u4e00-\u9fff]", input_str)
|
||||
pinyin_initials = "".join(
|
||||
[
|
||||
pinyin.get(c, format="strip", delimiter="")[0].upper()
|
||||
for c in chinese_chars
|
||||
]
|
||||
)
|
||||
return pinyin_initials
|
||||
|
||||
# 处理英文情况:拆分不同命名法的单词
|
||||
# 处理驼峰命名(大小写转换处拆分)
|
||||
s = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", input_str)
|
||||
# 处理中划线、下划线和其他非字母字符,统一替换为空格
|
||||
s = re.sub(r"[-_^&%$#@!~`]", " ", s)
|
||||
# 提取所有单词(忽略非字母字符)
|
||||
words = re.findall(r"[A-Za-z]+", s)
|
||||
# 统一转为小写后再处理(避免大小写影响)
|
||||
words = [word.lower() for word in words if word]
|
||||
|
||||
if not words:
|
||||
return "" # 空输入处理
|
||||
|
||||
# 定义少量单词与大量单词的阈值(这里使用3个作为分界)
|
||||
threshold = 3
|
||||
if len(words) <= threshold:
|
||||
# 少量单词:返回大驼峰格式
|
||||
return "".join([word.capitalize() for word in words])
|
||||
else:
|
||||
# 大量单词:返回纯大写字母(取每个单词首字母)
|
||||
return "".join([word[0].upper() for word in words])
|
||||
|
||||
|
||||
# Load the TOML file
|
||||
config = toml.load("pyproject.toml")
|
||||
|
||||
# Access specific values
|
||||
project_name = config.get("project", {}).get("name", None)
|
||||
project_description = config.get("project", {}).get("description", None)
|
||||
project_version = config.get("project", {}).get("version", None)
|
||||
project_authors = config.get("project", {}).get("authors", [])
|
||||
|
||||
|
||||
def start(startup: Callable | None = None) -> FastAPI:
|
||||
# from pyfiglet import FigletFont
|
||||
# fonts = FigletFont().getFonts()
|
||||
# for font in fonts:
|
||||
# try:
|
||||
# result = pyfiglet.figlet_format(project_name, font="red_phoenix")
|
||||
# print(f"Font: {font}")
|
||||
# print(result)
|
||||
# print("-" * 50)
|
||||
# except Exception as e:
|
||||
# logger.warning(f"Failed to render font {font}: {e}")
|
||||
#
|
||||
if not startup:
|
||||
raise ValueError("startup function is required")
|
||||
print("-" * 50)
|
||||
print(pyfiglet.figlet_format(project_name, font="red_phoenix"))
|
||||
print(f"Project Description: {project_description}")
|
||||
print(f"Version: {project_version}")
|
||||
print("Author: ", end="")
|
||||
[
|
||||
print(f"{author.get('name', '')} {author.get('email', '')}", end="")
|
||||
for author in project_authors
|
||||
]
|
||||
print()
|
||||
print("-" * 50)
|
||||
|
||||
return startup()
|
||||
Reference in New Issue
Block a user