Compare commits
	
		
			20 Commits
		
	
	
		
			v0.0.1-sna
			...
			v0.0.8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | b97c746d36 | ||
| 9793d96def | |||
|   | a6d9c23179 | ||
|   | 28a86dcbff | ||
| 7cd826b052 | |||
|   | da20e34dc9 | ||
|   | a43b5a96bb | ||
|   | 48aaa262c1 | ||
| 660839d854 | |||
|   | 6d23d601a8 | ||
|   | 7405f2cb88 | ||
|   | d415615ad7 | ||
|   | 6cd05ca4b4 | ||
| 97b2093b86 | |||
|   | c0aa618dfa | ||
|   | 16aa543431 | ||
| 24594deecc | |||
| 0c54293312 | |||
|   | f4383d195f | ||
|   | 10bc8fe7cb | 
							
								
								
									
										62
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | ||||
| # 依赖 | ||||
| node_modules | ||||
| npm-debug.log* | ||||
| yarn-debug.log* | ||||
| yarn-error.log* | ||||
| pnpm-debug.log* | ||||
|  | ||||
| # 构建产物 | ||||
| dist | ||||
| dist-ssr | ||||
| *.local | ||||
|  | ||||
| # 编辑器 | ||||
| .vscode | ||||
| .idea | ||||
| *.swp | ||||
| *.swo | ||||
| *~ | ||||
|  | ||||
| # 操作系统 | ||||
| .DS_Store | ||||
| Thumbs.db | ||||
|  | ||||
| # Git | ||||
| .git | ||||
| .gitignore | ||||
| .gitattributes | ||||
|  | ||||
| # CI/CD | ||||
| .gitea | ||||
| .github | ||||
| .gitlab-ci.yml | ||||
|  | ||||
| # Docker | ||||
| Dockerfile | ||||
| .dockerignore | ||||
| docker-compose.yaml | ||||
| docker-compose.*.yaml | ||||
| compose*yaml | ||||
|  | ||||
| # 测试 | ||||
| coverage | ||||
| .nyc_output | ||||
|  | ||||
| # 环境变量 | ||||
| .env | ||||
| .env.local | ||||
| .env.*.local | ||||
|  | ||||
| # 日志 | ||||
| logs | ||||
| *.log | ||||
|  | ||||
| # 临时文件 | ||||
| tmp | ||||
| temp | ||||
|  | ||||
| # 文档 | ||||
| README.md | ||||
| LICENSE | ||||
| *.md | ||||
|  | ||||
							
								
								
									
										57
									
								
								.gitea/workflows/build.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								.gitea/workflows/build.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| name: Build and Push Docker Image | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - 'v*' | ||||
|  | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: ubuntu-latest | ||||
|      | ||||
|     steps: | ||||
|       - name: 检出代码 | ||||
|         uses: actions/checkout@v4 | ||||
|          | ||||
|       - name: 设置 Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|          | ||||
|       - name: 登录到 Gitea Container Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ${{ secrets.REGISTRY_ENDPOINT }} | ||||
|           username: ${{ secrets.REGISTRY_USERNAME }} | ||||
|           password: ${{ secrets.REGISTRY_PASSWORD }} | ||||
|        | ||||
|       - name: 提取 Docker 元数据 | ||||
|         id: meta | ||||
|         uses: docker/metadata-action@v5 | ||||
|         with: | ||||
|           images: | | ||||
|             ${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek | ||||
|           tags: | | ||||
|             type=semver,pattern={{version}} | ||||
|             type=semver,pattern={{major}}.{{minor}} | ||||
|             type=semver,pattern={{major}} | ||||
|             type=raw,value=latest,enable=${{ !contains(github.ref, 'snapshot') && !contains(github.ref, 'alpha') && !contains(github.ref, 'beta') }} | ||||
|        | ||||
|       - name: 构建并推送 Docker 镜像 | ||||
|         id: build | ||||
|         uses: docker/build-push-action@v5 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           tags: ${{ steps.meta.outputs.tags }} | ||||
|           labels: ${{ steps.meta.outputs.labels }} | ||||
|           cache-from: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache | ||||
|           cache-to: type=registry,ref=${{ secrets.REGISTRY_ENDPOINT }}/${{ github.repository_owner }}/litek:buildcache,mode=max | ||||
|           # platforms: linux/amd64,linux/arm64 | ||||
|           platforms: linux/amd64 | ||||
|        | ||||
|       - name: 触发部署 workflow | ||||
|         uses: benc-uk/workflow-dispatch@v1 | ||||
|         with: | ||||
|           workflow: deploy.yaml | ||||
|           token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           inputs: '{"image_tag": "${{ github.ref_name }}"}' | ||||
|  | ||||
							
								
								
									
										82
									
								
								.gitea/workflows/deploy.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								.gitea/workflows/deploy.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| name: Deploy to Production | ||||
|  | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|     inputs: | ||||
|       image_tag: | ||||
|         description: '要部署的镜像标签 (例如: v1.0.0, latest)' | ||||
|         required: true | ||||
|         default: 'latest' | ||||
|       compose_path: | ||||
|         description: 'docker-compose 文件路径' | ||||
|         required: true | ||||
|         default: '/root/.compose/litek/compose.yaml' | ||||
|   workflow_call: | ||||
|     inputs: | ||||
|       image_tag: | ||||
|         description: '要部署的镜像标签' | ||||
|         required: true | ||||
|         type: string | ||||
|       compose_path: | ||||
|         description: 'docker-compose 文件路径' | ||||
|         required: false | ||||
|         type: string | ||||
|         default: '/root/.compose/litek/compose.yaml' | ||||
|  | ||||
| jobs: | ||||
|   deploy: | ||||
|     runs-on: ubuntu-latest | ||||
|      | ||||
|     steps: | ||||
|       - name: 解析 SSH 连接信息 | ||||
|         id: ssh-info | ||||
|         run: | | ||||
|           SSH_CONN="${{ secrets.SSH_CONNECTION }}" | ||||
|           # 格式: user@host:port | ||||
|           SSH_USER=$(echo $SSH_CONN | cut -d'@' -f1) | ||||
|           SSH_HOST_PORT=$(echo $SSH_CONN | cut -d'@' -f2) | ||||
|           SSH_HOST=$(echo $SSH_HOST_PORT | cut -d':' -f1) | ||||
|           SSH_PORT=$(echo $SSH_HOST_PORT | cut -d':' -f2) | ||||
|            | ||||
|           echo "user=$SSH_USER" >> $GITHUB_OUTPUT | ||||
|           echo "host=$SSH_HOST" >> $GITHUB_OUTPUT | ||||
|           echo "port=$SSH_PORT" >> $GITHUB_OUTPUT | ||||
|            | ||||
|           echo "SSH 连接信息已解析: $SSH_USER@$SSH_HOST:$SSH_PORT" | ||||
|        | ||||
|       - name: 部署到生产服务器 | ||||
|         uses: appleboy/ssh-action@v1.0.3 | ||||
|         with: | ||||
|           host: ${{ steps.ssh-info.outputs.host }} | ||||
|           username: ${{ steps.ssh-info.outputs.user }} | ||||
|           port: ${{ steps.ssh-info.outputs.port }} | ||||
|           key: ${{ secrets.SSH_PRIVATE_KEY }} | ||||
|           script: | | ||||
|             echo "开始部署 litek 应用..." | ||||
|             echo "镜像标签: ${{ inputs.image_tag }}" | ||||
|             echo "Compose 文件: ${{ inputs.compose_path }}" | ||||
|              | ||||
|             # 切换到 compose 文件所在目录 | ||||
|             COMPOSE_DIR=$(dirname "${{ inputs.compose_path }}") | ||||
|             cd "$COMPOSE_DIR" | ||||
|              | ||||
|             echo "当前目录: $(pwd)" | ||||
|              | ||||
|             # 拉取最新镜像 | ||||
|             echo "正在拉取镜像..." | ||||
|             docker compose -f "${{ inputs.compose_path }}" pull | ||||
|              | ||||
|             # 更新服务 | ||||
|             echo "正在更新服务..." | ||||
|             docker compose -f "${{ inputs.compose_path }}" up -d | ||||
|              | ||||
|             # 清理旧镜像 | ||||
|             echo "清理未使用的镜像..." | ||||
|             docker image prune -f | ||||
|              | ||||
|             echo "部署完成!" | ||||
|              | ||||
|             # 显示运行状态 | ||||
|             echo "当前运行的容器:" | ||||
|             docker compose -f "${{ inputs.compose_path }}" ps | ||||
|  | ||||
							
								
								
									
										37
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| # 第一阶段: 构建应用 | ||||
| FROM node:22-alpine AS builder | ||||
|  | ||||
| # 安装 pnpm | ||||
| RUN npm install -g pnpm | ||||
|  | ||||
| # 设置工作目录 | ||||
| WORKDIR /app | ||||
|  | ||||
| # 复制依赖配置文件 | ||||
| COPY package.json pnpm-lock.yaml ./ | ||||
|  | ||||
| # 安装依赖 | ||||
| RUN --mount=type=cache,target=/root/.local/share/pnpm/store \ | ||||
|     pnpm install --frozen-lockfile | ||||
|  | ||||
| # 复制源代码 | ||||
| COPY . . | ||||
|  | ||||
| # 构建应用 | ||||
| RUN pnpm build | ||||
|  | ||||
| # 第二阶段: 运行应用 | ||||
| FROM nginx:alpine | ||||
|  | ||||
| # 复制自定义 nginx 配置 | ||||
| COPY nginx.conf /etc/nginx/nginx.conf | ||||
|  | ||||
| # 从构建阶段复制构建产物 | ||||
| COPY --from=builder /app/dist /usr/share/nginx/html | ||||
|  | ||||
| # 暴露端口 | ||||
| EXPOSE 80 | ||||
|  | ||||
| # 启动 nginx | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
|  | ||||
							
								
								
									
										73
									
								
								nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								nginx.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| user nginx; | ||||
| worker_processes auto; | ||||
| error_log /var/log/nginx/error.log warn; | ||||
| pid /var/run/nginx.pid; | ||||
|  | ||||
| events { | ||||
|     worker_connections 1024; | ||||
| } | ||||
|  | ||||
| http { | ||||
|     include /etc/nginx/mime.types; | ||||
|     default_type application/octet-stream; | ||||
|  | ||||
|     log_format main '$remote_addr - $remote_user [$time_local] "$request" ' | ||||
|                     '$status $body_bytes_sent "$http_referer" ' | ||||
|                     '"$http_user_agent" "$http_x_forwarded_for"'; | ||||
|  | ||||
|     access_log /var/log/nginx/access.log main; | ||||
|  | ||||
|     sendfile on; | ||||
|     tcp_nopush on; | ||||
|     tcp_nodelay on; | ||||
|     keepalive_timeout 65; | ||||
|     types_hash_max_size 2048; | ||||
|  | ||||
|     # Gzip 压缩配置 | ||||
|     gzip on; | ||||
|     gzip_vary on; | ||||
|     gzip_proxied any; | ||||
|     gzip_comp_level 6; | ||||
|     gzip_types text/plain text/css text/xml text/javascript  | ||||
|                application/json application/javascript application/xml+rss  | ||||
|                application/rss+xml font/truetype font/opentype  | ||||
|                application/vnd.ms-fontobject image/svg+xml; | ||||
|  | ||||
|     server { | ||||
|         listen 80; | ||||
|         server_name localhost; | ||||
|         root /usr/share/nginx/html; | ||||
|         index index.html; | ||||
|  | ||||
|         # 安全头 | ||||
|         add_header X-Frame-Options "SAMEORIGIN" always; | ||||
|         add_header X-Content-Type-Options "nosniff" always; | ||||
|         add_header X-XSS-Protection "1; mode=block" always; | ||||
|  | ||||
|         # 静态资源缓存 | ||||
|         location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { | ||||
|             expires 1y; | ||||
|             add_header Cache-Control "public, immutable"; | ||||
|         } | ||||
|  | ||||
|         # SPA 路由支持 - 所有请求都返回 index.html | ||||
|         location / { | ||||
|             try_files $uri $uri/ /index.html; | ||||
|         } | ||||
|  | ||||
|         # 健康检查端点 | ||||
|         location /health { | ||||
|             access_log off; | ||||
|             return 200 "healthy\n"; | ||||
|             add_header Content-Type text/plain; | ||||
|         } | ||||
|  | ||||
|         # 错误页面 | ||||
|         error_page 404 /index.html; | ||||
|         error_page 500 502 503 504 /50x.html; | ||||
|         location = /50x.html { | ||||
|             root /usr/share/nginx/html; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										18
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								package.json
									
									
									
									
									
								
							| @@ -1,24 +1,36 @@ | ||||
| { | ||||
|   "name": "litek", | ||||
|   "private": true, | ||||
|   "version": "0.0.0", | ||||
|   "version": "0.0.8", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "dev": "vite", | ||||
|     "build": "tsc -b && vite build", | ||||
|     "lint": "eslint .", | ||||
|     "preview": "vite preview" | ||||
|     "preview": "vite preview", | ||||
|     "release:patch": "npm version patch && git push --follow-tags", | ||||
|     "release:minor": "npm version minor && git push --follow-tags", | ||||
|     "release:major": "npm version major && git push --follow-tags" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@radix-ui/react-dialog": "^1.1.15", | ||||
|     "@radix-ui/react-dropdown-menu": "^2.1.16", | ||||
|     "@radix-ui/react-separator": "^1.1.7", | ||||
|     "@radix-ui/react-slot": "^1.2.3", | ||||
|     "@radix-ui/react-tooltip": "^1.2.8", | ||||
|     "@tailwindcss/vite": "^4.1.16", | ||||
|     "class-variance-authority": "^0.7.1", | ||||
|     "clsx": "^2.1.1", | ||||
|     "lucide-react": "^0.548.0", | ||||
|     "nanoid": "^5.1.6", | ||||
|     "next-themes": "^0.4.6", | ||||
|     "react": "^19.1.1", | ||||
|     "react-dom": "^19.1.1", | ||||
|     "react-router-dom": "^7.9.4", | ||||
|     "sonner": "^2.0.7", | ||||
|     "tailwind-merge": "^3.3.1", | ||||
|     "tailwindcss": "^4.1.16" | ||||
|     "tailwindcss": "^4.1.16", | ||||
|     "uuid": "^13.0.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.36.0", | ||||
|   | ||||
							
								
								
									
										837
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										837
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										40
									
								
								src/components/sidebar/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/components/sidebar/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; | ||||
| import { tools } from "@/components/tool"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import { ModeToggle } from "@/components/theme/toggle"; | ||||
| import { Button } from "../ui/button"; | ||||
|  | ||||
| export const AppSidebar = () => ( | ||||
|   <Sidebar> | ||||
|     <SidebarHeader className="text-2xl font-bold flex justify-center items-center"> | ||||
|       Lite Kit | ||||
|     </SidebarHeader> | ||||
|     <SidebarContent> | ||||
|       <SidebarGroup> | ||||
|         <SidebarGroupLabel> | ||||
|           Tools | ||||
|         </SidebarGroupLabel> | ||||
|         <SidebarGroupContent> | ||||
|           { | ||||
|             tools.map((tool) => ( | ||||
|               <SidebarMenuItem key={tool.name}> | ||||
|                 <SidebarMenuButton asChild> | ||||
|                   <Link to={`/tool/${tool.path}`} title={tool.description}> | ||||
|                     {tool.icon} | ||||
|                     {tool.name} | ||||
|                   </Link> | ||||
|                 </SidebarMenuButton> | ||||
|               </SidebarMenuItem> | ||||
|             )) | ||||
|           } | ||||
|         </SidebarGroupContent> | ||||
|       </SidebarGroup> | ||||
|     </SidebarContent> | ||||
|     <SidebarFooter className="flex flex-row justify-between items-center gap-2"> | ||||
|       <Button variant="link"> | ||||
|         <a href="mailto:litek@mail.typist.cc">need more tools?</a> | ||||
|       </Button> | ||||
|       <ModeToggle /> | ||||
|     </SidebarFooter> | ||||
|   </Sidebar> | ||||
| ) | ||||
							
								
								
									
										73
									
								
								src/components/theme/provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								src/components/theme/provider.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import { createContext, useContext, useEffect, useState } from "react" | ||||
|  | ||||
| type Theme = "dark" | "light" | "system" | ||||
|  | ||||
| type ThemeProviderProps = { | ||||
|   children: React.ReactNode | ||||
|   defaultTheme?: Theme | ||||
|   storageKey?: string | ||||
| } | ||||
|  | ||||
| type ThemeProviderState = { | ||||
|   theme: Theme | ||||
|   setTheme: (theme: Theme) => void | ||||
| } | ||||
|  | ||||
| const initialState: ThemeProviderState = { | ||||
|   theme: "system", | ||||
|   setTheme: () => null, | ||||
| } | ||||
|  | ||||
| const ThemeProviderContext = createContext<ThemeProviderState>(initialState) | ||||
|  | ||||
| export function ThemeProvider({ | ||||
|   children, | ||||
|   defaultTheme = "system", | ||||
|   storageKey = "vite-ui-theme", | ||||
|   ...props | ||||
| }: ThemeProviderProps) { | ||||
|   const [theme, setTheme] = useState<Theme>( | ||||
|     () => (localStorage.getItem(storageKey) as Theme) || defaultTheme | ||||
|   ) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const root = window.document.documentElement | ||||
|  | ||||
|     root.classList.remove("light", "dark") | ||||
|  | ||||
|     if (theme === "system") { | ||||
|       const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") | ||||
|         .matches | ||||
|         ? "dark" | ||||
|         : "light" | ||||
|  | ||||
|       root.classList.add(systemTheme) | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     root.classList.add(theme) | ||||
|   }, [theme]) | ||||
|  | ||||
|   const value = { | ||||
|     theme, | ||||
|     setTheme: (theme: Theme) => { | ||||
|       localStorage.setItem(storageKey, theme) | ||||
|       setTheme(theme) | ||||
|     }, | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <ThemeProviderContext.Provider {...props} value={value}> | ||||
|       {children} | ||||
|     </ThemeProviderContext.Provider> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export const useTheme = () => { | ||||
|   const context = useContext(ThemeProviderContext) | ||||
|  | ||||
|   if (context === undefined) | ||||
|     throw new Error("useTheme must be used within a ThemeProvider") | ||||
|  | ||||
|   return context | ||||
| } | ||||
							
								
								
									
										38
									
								
								src/components/theme/toggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/theme/toggle.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import { Moon, Sun } from "lucide-react" | ||||
|  | ||||
| import { Button } from "@/components/ui/button" | ||||
| import { | ||||
|   DropdownMenu, | ||||
|   DropdownMenuContent, | ||||
|   DropdownMenuItem, | ||||
|   DropdownMenuTrigger, | ||||
| } from "@/components/ui/dropdown-menu" | ||||
|  | ||||
| import { useTheme } from "./provider" | ||||
|  | ||||
| export function ModeToggle() { | ||||
|   const { setTheme } = useTheme() | ||||
|  | ||||
|   return ( | ||||
|     <DropdownMenu> | ||||
|       <DropdownMenuTrigger asChild> | ||||
|         <Button variant="outline" size="icon"> | ||||
|           <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> | ||||
|           <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> | ||||
|           <span className="sr-only">Toggle theme</span> | ||||
|         </Button> | ||||
|       </DropdownMenuTrigger> | ||||
|       <DropdownMenuContent align="end"> | ||||
|         <DropdownMenuItem onClick={() => setTheme("light")}> | ||||
|           Light | ||||
|         </DropdownMenuItem> | ||||
|         <DropdownMenuItem onClick={() => setTheme("dark")}> | ||||
|           Dark | ||||
|         </DropdownMenuItem> | ||||
|         <DropdownMenuItem onClick={() => setTheme("system")}> | ||||
|           System | ||||
|         </DropdownMenuItem> | ||||
|       </DropdownMenuContent> | ||||
|     </DropdownMenu> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/components/tool/base64.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/components/tool/base64.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| import { useState, type FC } from "react"; | ||||
| import { Textarea } from "@/components/ui/textarea"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { toast } from "sonner"; | ||||
| import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [decoded, setDecoded] = useState<string>(""); | ||||
|   const [encoded, setEncoded] = useState<string>(""); | ||||
|  | ||||
|   const encode = () => { | ||||
|     try { | ||||
|       const encoded64 = btoa(decoded); | ||||
|       setEncoded(encoded64); | ||||
|       setDecoded(""); | ||||
|       toast.success("encoded successfully"); | ||||
|     } catch (error: unknown) { | ||||
|       if (error instanceof Error) { | ||||
|         toast.error(error.message); | ||||
|       } else { | ||||
|         toast.error("encoding failed"); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const decode = () => { | ||||
|     try { | ||||
|       const decoded64 = atob(encoded); | ||||
|       setDecoded(decoded64); | ||||
|       setEncoded(""); | ||||
|       toast.success("decoded successfully"); | ||||
|     } catch (error: unknown) { | ||||
|       if (error instanceof Error) { | ||||
|         toast.error(error.message); | ||||
|       } else { | ||||
|         toast.error("decoding failed"); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="h-[50vh] flex flex-row gap-4 pt-[20vh]"> | ||||
|       <Textarea | ||||
|         className="flex-1 resize-none" | ||||
|         placeholder="Enter the original text" | ||||
|         value={decoded} | ||||
|         onChange={(e) => setDecoded(e.target.value)} | ||||
|       /> | ||||
|       <div className="flex flex-col gap-2 justify-center"> | ||||
|         <Button onClick={encode}> | ||||
|           <ArrowRightIcon className="size-4" /> | ||||
|           Encode | ||||
|         </Button> | ||||
|         <Button onClick={decode}> | ||||
|           <ArrowLeftIcon className="size-4" /> | ||||
|           Decode | ||||
|         </Button> | ||||
|       </div> | ||||
|       <Textarea | ||||
|         className="flex-1 resize-none" | ||||
|         placeholder="Enter the Base64 encoded text" | ||||
|         value={encoded} | ||||
|         onChange={(e) => setEncoded(e.target.value)} | ||||
|       /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
|  | ||||
							
								
								
									
										38
									
								
								src/components/tool/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/tool/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import type { ReactNode } from 'react'; | ||||
| import { FileJson, Hash, Binary } from 'lucide-react' | ||||
|  | ||||
| import UUID from './uuid' | ||||
| import JSON from './json' | ||||
| import Base64 from './base64' | ||||
|  | ||||
| export interface Tool { | ||||
|   path: string; | ||||
|   name: string; | ||||
|   icon: ReactNode; | ||||
|   description: string; | ||||
|   component: ReactNode; | ||||
| } | ||||
|  | ||||
| export const tools: Tool[] = [ | ||||
|   { | ||||
|     path: "uuid", | ||||
|     name: "UUID Generator", | ||||
|     description: "Generate a UUID", | ||||
|     icon: <Hash />, | ||||
|     component: <UUID />, | ||||
|   }, | ||||
|   { | ||||
|     path: "json", | ||||
|     name: "JSON Formatter", | ||||
|     description: "Format and validate JSON", | ||||
|     icon: <FileJson />, | ||||
|     component: <JSON />, | ||||
|   }, | ||||
|   { | ||||
|     path: "base64", | ||||
|     name: "Base64 Encoder/Decoder", | ||||
|     description: "Encode and decode Base64", | ||||
|     icon: <Binary />, | ||||
|     component: <Base64 />, | ||||
|   } | ||||
| ]; | ||||
							
								
								
									
										48
									
								
								src/components/tool/json.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/components/tool/json.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import { useState, type FC } from "react"; | ||||
| import { Textarea } from "@/components/ui/textarea"; | ||||
| import { Button } from "@/components/ui/button"; | ||||
| import { toast } from "sonner"; | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   const [json, setJson] = useState<string>(""); | ||||
|  | ||||
|   const validateJson = () => { | ||||
|     try { | ||||
|       JSON.parse(json); | ||||
|       return true; | ||||
|     } catch (error: unknown) { | ||||
|       if (error instanceof Error) { | ||||
|         toast.error(error.message); | ||||
|       } else { | ||||
|         toast.error("Invalid JSON"); | ||||
|       } | ||||
|       return false; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const minifyJson = () => { | ||||
|     if (!validateJson()) return; | ||||
|     const formattedJson = JSON.stringify(JSON.parse(json), null, 0); | ||||
|     setJson(formattedJson); | ||||
|     toast.success("Minified successfully"); | ||||
|   }; | ||||
|  | ||||
|   const prettifyJson = () => { | ||||
|     if (!validateJson()) return; | ||||
|     const formattedJson = JSON.stringify(JSON.parse(json), null, 2); | ||||
|     setJson(formattedJson); | ||||
|     toast.success("JSON prettified successfully"); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="h-full flex flex-col gap-4"> | ||||
|       <Textarea className="flex-1 w-full resize-none" placeholder="Enter your JSON here" value={json} onChange={(e) => setJson(e.target.value)} /> | ||||
|       <div className="flex flex-row gap-2"> | ||||
|         <Button onClick={minifyJson}>Minify</Button> | ||||
|         <Button onClick={prettifyJson}>Pretty</Button> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
							
								
								
									
										24
									
								
								src/components/tool/uuid.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/components/tool/uuid.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import { type FC } from "react"; | ||||
|  | ||||
| import * as uuid from 'uuid' | ||||
| import { nanoid } from 'nanoid' | ||||
|  | ||||
| const Tool: FC = () => { | ||||
|   return ( | ||||
|     <div className="flex flex-col gap-4"> | ||||
|       <span className="text-sm text-muted-foreground">Refresh the page to generate new UUID</span> | ||||
|       <label>UUID Version 1</label> | ||||
|       <span>{uuid.v1()}</span> | ||||
|       <label>UUID Version 4</label> | ||||
|       <span>{uuid.v4()}</span> | ||||
|       <label>UUID Version 6</label> | ||||
|       <span>{uuid.v6()}</span> | ||||
|       <label>UUID Version 7</label> | ||||
|       <span>{uuid.v7()}</span> | ||||
|       <label>Nano ID</label> | ||||
|       <span>{nanoid()}</span> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default Tool; | ||||
							
								
								
									
										66
									
								
								src/components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,66 @@ | ||||
| import * as React from "react" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
|  | ||||
| const alertVariants = cva( | ||||
|   "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "bg-card text-card-foreground", | ||||
|         destructive: | ||||
|           "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
|  | ||||
| function Alert({ | ||||
|   className, | ||||
|   variant, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="alert" | ||||
|       role="alert" | ||||
|       className={cn(alertVariants({ variant }), className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="alert-title" | ||||
|       className={cn( | ||||
|         "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function AlertDescription({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="alert-description" | ||||
|       className={cn( | ||||
|         "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { Alert, AlertTitle, AlertDescription } | ||||
							
								
								
									
										255
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								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, | ||||
| } | ||||
							
								
								
									
										21
									
								
								src/components/ui/input.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								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 } | ||||
							
								
								
									
										26
									
								
								src/components/ui/separator.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								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 } | ||||
							
								
								
									
										139
									
								
								src/components/ui/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/components/ui/sheet.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| "use client" | ||||
|  | ||||
| import * as React from "react" | ||||
| import * as SheetPrimitive from "@radix-ui/react-dialog" | ||||
| import { XIcon } from "lucide-react" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
|  | ||||
| function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { | ||||
|   return <SheetPrimitive.Root data-slot="sheet" {...props} /> | ||||
| } | ||||
|  | ||||
| function SheetTrigger({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Trigger>) { | ||||
|   return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> | ||||
| } | ||||
|  | ||||
| function SheetClose({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Close>) { | ||||
|   return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> | ||||
| } | ||||
|  | ||||
| function SheetPortal({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Portal>) { | ||||
|   return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> | ||||
| } | ||||
|  | ||||
| function SheetOverlay({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Overlay>) { | ||||
|   return ( | ||||
|     <SheetPrimitive.Overlay | ||||
|       data-slot="sheet-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 SheetContent({ | ||||
|   className, | ||||
|   children, | ||||
|   side = "right", | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Content> & { | ||||
|   side?: "top" | "right" | "bottom" | "left" | ||||
| }) { | ||||
|   return ( | ||||
|     <SheetPortal> | ||||
|       <SheetOverlay /> | ||||
|       <SheetPrimitive.Content | ||||
|         data-slot="sheet-content" | ||||
|         className={cn( | ||||
|           "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", | ||||
|           side === "right" && | ||||
|             "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm", | ||||
|           side === "left" && | ||||
|             "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm", | ||||
|           side === "top" && | ||||
|             "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", | ||||
|           side === "bottom" && | ||||
|             "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       > | ||||
|         {children} | ||||
|         <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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"> | ||||
|           <XIcon className="size-4" /> | ||||
|           <span className="sr-only">Close</span> | ||||
|         </SheetPrimitive.Close> | ||||
|       </SheetPrimitive.Content> | ||||
|     </SheetPortal> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sheet-header" | ||||
|       className={cn("flex flex-col gap-1.5 p-4", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sheet-footer" | ||||
|       className={cn("mt-auto flex flex-col gap-2 p-4", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SheetTitle({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Title>) { | ||||
|   return ( | ||||
|     <SheetPrimitive.Title | ||||
|       data-slot="sheet-title" | ||||
|       className={cn("text-foreground font-semibold", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SheetDescription({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof SheetPrimitive.Description>) { | ||||
|   return ( | ||||
|     <SheetPrimitive.Description | ||||
|       data-slot="sheet-description" | ||||
|       className={cn("text-muted-foreground text-sm", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Sheet, | ||||
|   SheetTrigger, | ||||
|   SheetClose, | ||||
|   SheetContent, | ||||
|   SheetHeader, | ||||
|   SheetFooter, | ||||
|   SheetTitle, | ||||
|   SheetDescription, | ||||
| } | ||||
							
								
								
									
										726
									
								
								src/components/ui/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										726
									
								
								src/components/ui/sidebar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,726 @@ | ||||
| "use client" | ||||
|  | ||||
| import * as React from "react" | ||||
| import { Slot } from "@radix-ui/react-slot" | ||||
| import { cva, type VariantProps } from "class-variance-authority" | ||||
| import { PanelLeftIcon } from "lucide-react" | ||||
|  | ||||
| import { useIsMobile } from "@/hooks/use-mobile" | ||||
| import { cn } from "@/lib/utils" | ||||
| import { Button } from "@/components/ui/button" | ||||
| import { Input } from "@/components/ui/input" | ||||
| import { Separator } from "@/components/ui/separator" | ||||
| import { | ||||
|   Sheet, | ||||
|   SheetContent, | ||||
|   SheetDescription, | ||||
|   SheetHeader, | ||||
|   SheetTitle, | ||||
| } from "@/components/ui/sheet" | ||||
| import { Skeleton } from "@/components/ui/skeleton" | ||||
| import { | ||||
|   Tooltip, | ||||
|   TooltipContent, | ||||
|   TooltipProvider, | ||||
|   TooltipTrigger, | ||||
| } from "@/components/ui/tooltip" | ||||
|  | ||||
| const SIDEBAR_COOKIE_NAME = "sidebar_state" | ||||
| const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 | ||||
| const SIDEBAR_WIDTH = "16rem" | ||||
| const SIDEBAR_WIDTH_MOBILE = "18rem" | ||||
| const SIDEBAR_WIDTH_ICON = "3rem" | ||||
| const SIDEBAR_KEYBOARD_SHORTCUT = "b" | ||||
|  | ||||
| type SidebarContextProps = { | ||||
|   state: "expanded" | "collapsed" | ||||
|   open: boolean | ||||
|   setOpen: (open: boolean) => void | ||||
|   openMobile: boolean | ||||
|   setOpenMobile: (open: boolean) => void | ||||
|   isMobile: boolean | ||||
|   toggleSidebar: () => void | ||||
| } | ||||
|  | ||||
| const SidebarContext = React.createContext<SidebarContextProps | null>(null) | ||||
|  | ||||
| function useSidebar() { | ||||
|   const context = React.useContext(SidebarContext) | ||||
|   if (!context) { | ||||
|     throw new Error("useSidebar must be used within a SidebarProvider.") | ||||
|   } | ||||
|  | ||||
|   return context | ||||
| } | ||||
|  | ||||
| function SidebarProvider({ | ||||
|   defaultOpen = true, | ||||
|   open: openProp, | ||||
|   onOpenChange: setOpenProp, | ||||
|   className, | ||||
|   style, | ||||
|   children, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div"> & { | ||||
|   defaultOpen?: boolean | ||||
|   open?: boolean | ||||
|   onOpenChange?: (open: boolean) => void | ||||
| }) { | ||||
|   const isMobile = useIsMobile() | ||||
|   const [openMobile, setOpenMobile] = React.useState(false) | ||||
|  | ||||
|   // This is the internal state of the sidebar. | ||||
|   // We use openProp and setOpenProp for control from outside the component. | ||||
|   const [_open, _setOpen] = React.useState(defaultOpen) | ||||
|   const open = openProp ?? _open | ||||
|   const setOpen = React.useCallback( | ||||
|     (value: boolean | ((value: boolean) => boolean)) => { | ||||
|       const openState = typeof value === "function" ? value(open) : value | ||||
|       if (setOpenProp) { | ||||
|         setOpenProp(openState) | ||||
|       } else { | ||||
|         _setOpen(openState) | ||||
|       } | ||||
|  | ||||
|       // This sets the cookie to keep the sidebar state. | ||||
|       document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` | ||||
|     }, | ||||
|     [setOpenProp, open] | ||||
|   ) | ||||
|  | ||||
|   // Helper to toggle the sidebar. | ||||
|   const toggleSidebar = React.useCallback(() => { | ||||
|     return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) | ||||
|   }, [isMobile, setOpen, setOpenMobile]) | ||||
|  | ||||
|   // Adds a keyboard shortcut to toggle the sidebar. | ||||
|   React.useEffect(() => { | ||||
|     const handleKeyDown = (event: KeyboardEvent) => { | ||||
|       if ( | ||||
|         event.key === SIDEBAR_KEYBOARD_SHORTCUT && | ||||
|         (event.metaKey || event.ctrlKey) | ||||
|       ) { | ||||
|         event.preventDefault() | ||||
|         toggleSidebar() | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     window.addEventListener("keydown", handleKeyDown) | ||||
|     return () => window.removeEventListener("keydown", handleKeyDown) | ||||
|   }, [toggleSidebar]) | ||||
|  | ||||
|   // We add a state so that we can do data-state="expanded" or "collapsed". | ||||
|   // This makes it easier to style the sidebar with Tailwind classes. | ||||
|   const state = open ? "expanded" : "collapsed" | ||||
|  | ||||
|   const contextValue = React.useMemo<SidebarContextProps>( | ||||
|     () => ({ | ||||
|       state, | ||||
|       open, | ||||
|       setOpen, | ||||
|       isMobile, | ||||
|       openMobile, | ||||
|       setOpenMobile, | ||||
|       toggleSidebar, | ||||
|     }), | ||||
|     [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] | ||||
|   ) | ||||
|  | ||||
|   return ( | ||||
|     <SidebarContext.Provider value={contextValue}> | ||||
|       <TooltipProvider delayDuration={0}> | ||||
|         <div | ||||
|           data-slot="sidebar-wrapper" | ||||
|           style={ | ||||
|             { | ||||
|               "--sidebar-width": SIDEBAR_WIDTH, | ||||
|               "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, | ||||
|               ...style, | ||||
|             } as React.CSSProperties | ||||
|           } | ||||
|           className={cn( | ||||
|             "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", | ||||
|             className | ||||
|           )} | ||||
|           {...props} | ||||
|         > | ||||
|           {children} | ||||
|         </div> | ||||
|       </TooltipProvider> | ||||
|     </SidebarContext.Provider> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function Sidebar({ | ||||
|   side = "left", | ||||
|   variant = "sidebar", | ||||
|   collapsible = "offcanvas", | ||||
|   className, | ||||
|   children, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div"> & { | ||||
|   side?: "left" | "right" | ||||
|   variant?: "sidebar" | "floating" | "inset" | ||||
|   collapsible?: "offcanvas" | "icon" | "none" | ||||
| }) { | ||||
|   const { isMobile, state, openMobile, setOpenMobile } = useSidebar() | ||||
|  | ||||
|   if (collapsible === "none") { | ||||
|     return ( | ||||
|       <div | ||||
|         data-slot="sidebar" | ||||
|         className={cn( | ||||
|           "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       > | ||||
|         {children} | ||||
|       </div> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   if (isMobile) { | ||||
|     return ( | ||||
|       <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> | ||||
|         <SheetContent | ||||
|           data-sidebar="sidebar" | ||||
|           data-slot="sidebar" | ||||
|           data-mobile="true" | ||||
|           className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden" | ||||
|           style={ | ||||
|             { | ||||
|               "--sidebar-width": SIDEBAR_WIDTH_MOBILE, | ||||
|             } as React.CSSProperties | ||||
|           } | ||||
|           side={side} | ||||
|         > | ||||
|           <SheetHeader className="sr-only"> | ||||
|             <SheetTitle>Sidebar</SheetTitle> | ||||
|             <SheetDescription>Displays the mobile sidebar.</SheetDescription> | ||||
|           </SheetHeader> | ||||
|           <div className="flex h-full w-full flex-col">{children}</div> | ||||
|         </SheetContent> | ||||
|       </Sheet> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className="group peer text-sidebar-foreground hidden md:block" | ||||
|       data-state={state} | ||||
|       data-collapsible={state === "collapsed" ? collapsible : ""} | ||||
|       data-variant={variant} | ||||
|       data-side={side} | ||||
|       data-slot="sidebar" | ||||
|     > | ||||
|       {/* This is what handles the sidebar gap on desktop */} | ||||
|       <div | ||||
|         data-slot="sidebar-gap" | ||||
|         className={cn( | ||||
|           "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear", | ||||
|           "group-data-[collapsible=offcanvas]:w-0", | ||||
|           "group-data-[side=right]:rotate-180", | ||||
|           variant === "floating" || variant === "inset" | ||||
|             ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" | ||||
|             : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" | ||||
|         )} | ||||
|       /> | ||||
|       <div | ||||
|         data-slot="sidebar-container" | ||||
|         className={cn( | ||||
|           "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", | ||||
|           side === "left" | ||||
|             ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" | ||||
|             : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", | ||||
|           // Adjust the padding for floating and inset variants. | ||||
|           variant === "floating" || variant === "inset" | ||||
|             ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" | ||||
|             : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       > | ||||
|         <div | ||||
|           data-sidebar="sidebar" | ||||
|           data-slot="sidebar-inner" | ||||
|           className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" | ||||
|         > | ||||
|           {children} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarTrigger({ | ||||
|   className, | ||||
|   onClick, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Button>) { | ||||
|   const { toggleSidebar } = useSidebar() | ||||
|  | ||||
|   return ( | ||||
|     <Button | ||||
|       data-sidebar="trigger" | ||||
|       data-slot="sidebar-trigger" | ||||
|       variant="ghost" | ||||
|       size="icon" | ||||
|       className={cn("size-7", className)} | ||||
|       onClick={(event) => { | ||||
|         onClick?.(event) | ||||
|         toggleSidebar() | ||||
|       }} | ||||
|       {...props} | ||||
|     > | ||||
|       <PanelLeftIcon /> | ||||
|       <span className="sr-only">Toggle Sidebar</span> | ||||
|     </Button> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { | ||||
|   const { toggleSidebar } = useSidebar() | ||||
|  | ||||
|   return ( | ||||
|     <button | ||||
|       data-sidebar="rail" | ||||
|       data-slot="sidebar-rail" | ||||
|       aria-label="Toggle Sidebar" | ||||
|       tabIndex={-1} | ||||
|       onClick={toggleSidebar} | ||||
|       title="Toggle Sidebar" | ||||
|       className={cn( | ||||
|         "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", | ||||
|         "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", | ||||
|         "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", | ||||
|         "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", | ||||
|         "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", | ||||
|         "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { | ||||
|   return ( | ||||
|     <main | ||||
|       data-slot="sidebar-inset" | ||||
|       className={cn( | ||||
|         "bg-background relative flex w-full flex-1 flex-col", | ||||
|         "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarInput({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Input>) { | ||||
|   return ( | ||||
|     <Input | ||||
|       data-slot="sidebar-input" | ||||
|       data-sidebar="input" | ||||
|       className={cn("bg-background h-8 w-full shadow-none", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-header" | ||||
|       data-sidebar="header" | ||||
|       className={cn("flex flex-col gap-2 p-2", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-footer" | ||||
|       data-sidebar="footer" | ||||
|       className={cn("flex flex-col gap-2 p-2", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarSeparator({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Separator>) { | ||||
|   return ( | ||||
|     <Separator | ||||
|       data-slot="sidebar-separator" | ||||
|       data-sidebar="separator" | ||||
|       className={cn("bg-sidebar-border mx-2 w-auto", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-content" | ||||
|       data-sidebar="content" | ||||
|       className={cn( | ||||
|         "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-group" | ||||
|       data-sidebar="group" | ||||
|       className={cn("relative flex w-full min-w-0 flex-col p-2", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarGroupLabel({ | ||||
|   className, | ||||
|   asChild = false, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div"> & { asChild?: boolean }) { | ||||
|   const Comp = asChild ? Slot : "div" | ||||
|  | ||||
|   return ( | ||||
|     <Comp | ||||
|       data-slot="sidebar-group-label" | ||||
|       data-sidebar="group-label" | ||||
|       className={cn( | ||||
|         "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarGroupAction({ | ||||
|   className, | ||||
|   asChild = false, | ||||
|   ...props | ||||
| }: React.ComponentProps<"button"> & { asChild?: boolean }) { | ||||
|   const Comp = asChild ? Slot : "button" | ||||
|  | ||||
|   return ( | ||||
|     <Comp | ||||
|       data-slot="sidebar-group-action" | ||||
|       data-sidebar="group-action" | ||||
|       className={cn( | ||||
|         "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         // Increases the hit area of the button on mobile. | ||||
|         "after:absolute after:-inset-2 md:after:hidden", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarGroupContent({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-group-content" | ||||
|       data-sidebar="group-content" | ||||
|       className={cn("w-full text-sm", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { | ||||
|   return ( | ||||
|     <ul | ||||
|       data-slot="sidebar-menu" | ||||
|       data-sidebar="menu" | ||||
|       className={cn("flex w-full min-w-0 flex-col gap-1", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { | ||||
|   return ( | ||||
|     <li | ||||
|       data-slot="sidebar-menu-item" | ||||
|       data-sidebar="menu-item" | ||||
|       className={cn("group/menu-item relative", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| const sidebarMenuButtonVariants = cva( | ||||
|   "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|   { | ||||
|     variants: { | ||||
|       variant: { | ||||
|         default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", | ||||
|         outline: | ||||
|           "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", | ||||
|       }, | ||||
|       size: { | ||||
|         default: "h-8 text-sm", | ||||
|         sm: "h-7 text-xs", | ||||
|         lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!", | ||||
|       }, | ||||
|     }, | ||||
|     defaultVariants: { | ||||
|       variant: "default", | ||||
|       size: "default", | ||||
|     }, | ||||
|   } | ||||
| ) | ||||
|  | ||||
| function SidebarMenuButton({ | ||||
|   asChild = false, | ||||
|   isActive = false, | ||||
|   variant = "default", | ||||
|   size = "default", | ||||
|   tooltip, | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"button"> & { | ||||
|   asChild?: boolean | ||||
|   isActive?: boolean | ||||
|   tooltip?: string | React.ComponentProps<typeof TooltipContent> | ||||
| } & VariantProps<typeof sidebarMenuButtonVariants>) { | ||||
|   const Comp = asChild ? Slot : "button" | ||||
|   const { isMobile, state } = useSidebar() | ||||
|  | ||||
|   const button = ( | ||||
|     <Comp | ||||
|       data-slot="sidebar-menu-button" | ||||
|       data-sidebar="menu-button" | ||||
|       data-size={size} | ||||
|       data-active={isActive} | ||||
|       className={cn(sidebarMenuButtonVariants({ variant, size }), className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
|  | ||||
|   if (!tooltip) { | ||||
|     return button | ||||
|   } | ||||
|  | ||||
|   if (typeof tooltip === "string") { | ||||
|     tooltip = { | ||||
|       children: tooltip, | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Tooltip> | ||||
|       <TooltipTrigger asChild>{button}</TooltipTrigger> | ||||
|       <TooltipContent | ||||
|         side="right" | ||||
|         align="center" | ||||
|         hidden={state !== "collapsed" || isMobile} | ||||
|         {...tooltip} | ||||
|       /> | ||||
|     </Tooltip> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuAction({ | ||||
|   className, | ||||
|   asChild = false, | ||||
|   showOnHover = false, | ||||
|   ...props | ||||
| }: React.ComponentProps<"button"> & { | ||||
|   asChild?: boolean | ||||
|   showOnHover?: boolean | ||||
| }) { | ||||
|   const Comp = asChild ? Slot : "button" | ||||
|  | ||||
|   return ( | ||||
|     <Comp | ||||
|       data-slot="sidebar-menu-action" | ||||
|       data-sidebar="menu-action" | ||||
|       className={cn( | ||||
|         "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         // Increases the hit area of the button on mobile. | ||||
|         "after:absolute after:-inset-2 md:after:hidden", | ||||
|         "peer-data-[size=sm]/menu-button:top-1", | ||||
|         "peer-data-[size=default]/menu-button:top-1.5", | ||||
|         "peer-data-[size=lg]/menu-button:top-2.5", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         showOnHover && | ||||
|           "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuBadge({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-menu-badge" | ||||
|       data-sidebar="menu-badge" | ||||
|       className={cn( | ||||
|         "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none", | ||||
|         "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", | ||||
|         "peer-data-[size=sm]/menu-button:top-1", | ||||
|         "peer-data-[size=default]/menu-button:top-1.5", | ||||
|         "peer-data-[size=lg]/menu-button:top-2.5", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuSkeleton({ | ||||
|   className, | ||||
|   showIcon = false, | ||||
|   ...props | ||||
| }: React.ComponentProps<"div"> & { | ||||
|   showIcon?: boolean | ||||
| }) { | ||||
|   // Random width between 50 to 90%. | ||||
|   const width = React.useMemo(() => { | ||||
|     return `${Math.floor(Math.random() * 40) + 50}%` | ||||
|   }, []) | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="sidebar-menu-skeleton" | ||||
|       data-sidebar="menu-skeleton" | ||||
|       className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} | ||||
|       {...props} | ||||
|     > | ||||
|       {showIcon && ( | ||||
|         <Skeleton | ||||
|           className="size-4 rounded-md" | ||||
|           data-sidebar="menu-skeleton-icon" | ||||
|         /> | ||||
|       )} | ||||
|       <Skeleton | ||||
|         className="h-4 max-w-(--skeleton-width) flex-1" | ||||
|         data-sidebar="menu-skeleton-text" | ||||
|         style={ | ||||
|           { | ||||
|             "--skeleton-width": width, | ||||
|           } as React.CSSProperties | ||||
|         } | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { | ||||
|   return ( | ||||
|     <ul | ||||
|       data-slot="sidebar-menu-sub" | ||||
|       data-sidebar="menu-sub" | ||||
|       className={cn( | ||||
|         "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuSubItem({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"li">) { | ||||
|   return ( | ||||
|     <li | ||||
|       data-slot="sidebar-menu-sub-item" | ||||
|       data-sidebar="menu-sub-item" | ||||
|       className={cn("group/menu-sub-item relative", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function SidebarMenuSubButton({ | ||||
|   asChild = false, | ||||
|   size = "md", | ||||
|   isActive = false, | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"a"> & { | ||||
|   asChild?: boolean | ||||
|   size?: "sm" | "md" | ||||
|   isActive?: boolean | ||||
| }) { | ||||
|   const Comp = asChild ? Slot : "a" | ||||
|  | ||||
|   return ( | ||||
|     <Comp | ||||
|       data-slot="sidebar-menu-sub-button" | ||||
|       data-sidebar="menu-sub-button" | ||||
|       data-size={size} | ||||
|       data-active={isActive} | ||||
|       className={cn( | ||||
|         "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", | ||||
|         "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", | ||||
|         size === "sm" && "text-xs", | ||||
|         size === "md" && "text-sm", | ||||
|         "group-data-[collapsible=icon]:hidden", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Sidebar, | ||||
|   SidebarContent, | ||||
|   SidebarFooter, | ||||
|   SidebarGroup, | ||||
|   SidebarGroupAction, | ||||
|   SidebarGroupContent, | ||||
|   SidebarGroupLabel, | ||||
|   SidebarHeader, | ||||
|   SidebarInput, | ||||
|   SidebarInset, | ||||
|   SidebarMenu, | ||||
|   SidebarMenuAction, | ||||
|   SidebarMenuBadge, | ||||
|   SidebarMenuButton, | ||||
|   SidebarMenuItem, | ||||
|   SidebarMenuSkeleton, | ||||
|   SidebarMenuSub, | ||||
|   SidebarMenuSubButton, | ||||
|   SidebarMenuSubItem, | ||||
|   SidebarProvider, | ||||
|   SidebarRail, | ||||
|   SidebarSeparator, | ||||
|   SidebarTrigger, | ||||
|   useSidebar, | ||||
| } | ||||
							
								
								
									
										13
									
								
								src/components/ui/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/components/ui/skeleton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { cn } from "@/lib/utils" | ||||
|  | ||||
| function Skeleton({ className, ...props }: React.ComponentProps<"div">) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="skeleton" | ||||
|       className={cn("bg-accent animate-pulse rounded-md", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { Skeleton } | ||||
							
								
								
									
										38
									
								
								src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/components/ui/sonner.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | ||||
| import { | ||||
|   CircleCheckIcon, | ||||
|   InfoIcon, | ||||
|   Loader2Icon, | ||||
|   OctagonXIcon, | ||||
|   TriangleAlertIcon, | ||||
| } from "lucide-react" | ||||
| import { useTheme } from "next-themes" | ||||
| import { Toaster as Sonner, type ToasterProps } from "sonner" | ||||
|  | ||||
| const Toaster = ({ ...props }: ToasterProps) => { | ||||
|   const { theme = "system" } = useTheme() | ||||
|  | ||||
|   return ( | ||||
|     <Sonner | ||||
|       theme={theme as ToasterProps["theme"]} | ||||
|       className="toaster group" | ||||
|       icons={{ | ||||
|         success: <CircleCheckIcon className="size-4" />, | ||||
|         info: <InfoIcon className="size-4" />, | ||||
|         warning: <TriangleAlertIcon className="size-4" />, | ||||
|         error: <OctagonXIcon className="size-4" />, | ||||
|         loading: <Loader2Icon className="size-4 animate-spin" />, | ||||
|       }} | ||||
|       style={ | ||||
|         { | ||||
|           "--normal-bg": "var(--popover)", | ||||
|           "--normal-text": "var(--popover-foreground)", | ||||
|           "--normal-border": "var(--border)", | ||||
|           "--border-radius": "var(--radius)", | ||||
|         } as React.CSSProperties | ||||
|       } | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { Toaster } | ||||
							
								
								
									
										18
									
								
								src/components/ui/textarea.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								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
									
								
								src/components/ui/tooltip.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								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 } | ||||
							
								
								
									
										23
									
								
								src/globals.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/globals.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| @layer base { | ||||
|   :root { | ||||
|     --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 { | ||||
|     --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.439 0 0); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/hooks/use-mobile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/hooks/use-mobile.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import * as React from "react" | ||||
|  | ||||
| const MOBILE_BREAKPOINT = 768 | ||||
|  | ||||
| export function useIsMobile() { | ||||
|   const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) | ||||
|     const onChange = () => { | ||||
|       setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) | ||||
|     } | ||||
|     mql.addEventListener("change", onChange) | ||||
|     setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) | ||||
|     return () => mql.removeEventListener("change", onChange) | ||||
|   }, []) | ||||
|  | ||||
|   return !!isMobile | ||||
| } | ||||
							
								
								
									
										23
									
								
								src/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| import type { FC } from "react" | ||||
| import { Outlet } from "react-router-dom"; | ||||
|  | ||||
| import { ThemeProvider } from "@/components/theme/provider" | ||||
| import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" | ||||
| import { AppSidebar } from "@/components/sidebar"; | ||||
|  | ||||
| export const Layout: FC = () => ( | ||||
|   <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> | ||||
|     <SidebarProvider> | ||||
|       <AppSidebar /> | ||||
|       <div className="p-4 flex flex-col w-full h-[100vh] overflow-hidden"> | ||||
|         <nav className="flex items-center justify-between"> | ||||
|           <SidebarTrigger className="size-10" /> | ||||
|           <div role="actions" /> | ||||
|         </nav> | ||||
|         <main className="flex-1 overflow-auto p-4 overflow-hidden"> | ||||
|           <Outlet /> | ||||
|         </main> | ||||
|       </div> | ||||
|     </SidebarProvider> | ||||
|   </ThemeProvider> | ||||
| ); | ||||
| @@ -1,9 +1,15 @@ | ||||
| import { StrictMode } from 'react' | ||||
| import { createRoot } from 'react-dom/client' | ||||
|  | ||||
| import { Toaster } from '@/components/ui/sonner' | ||||
|  | ||||
|  | ||||
| import './index.css' | ||||
| import { AppRouter } from './router' | ||||
|  | ||||
| createRoot(document.getElementById('root')!).render( | ||||
|   <StrictMode> | ||||
|     {null} | ||||
|     <AppRouter /> | ||||
|     <Toaster /> | ||||
|   </StrictMode> | ||||
| ) | ||||
|   | ||||
							
								
								
									
										44
									
								
								src/router.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/router.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| import { | ||||
|   createBrowserRouter, | ||||
|   redirect, | ||||
|   RouterProvider, | ||||
| } from "react-router-dom"; | ||||
|  | ||||
| import { tools } from "@/components/tool"; | ||||
| import { Layout } from "./layout"; | ||||
|  | ||||
| // 路由配置 | ||||
| const router = createBrowserRouter([ | ||||
|   { | ||||
|     path: "", | ||||
|     element: <Layout />, | ||||
|     children: [ | ||||
|       { | ||||
|         path: "tool", | ||||
|         children: [ | ||||
|           ...tools.map((tool) => ( | ||||
|             { | ||||
|               path: tool.path, | ||||
|               element: tool.component, | ||||
|             } | ||||
|           )), | ||||
|           { | ||||
|             index: true, | ||||
|             loader: () => redirect("/tool/uuid"), | ||||
|           }, | ||||
|         ] | ||||
|       }, | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     index: true, | ||||
|     loader: () => redirect("/tool"), | ||||
|   }, | ||||
|   { | ||||
|     path: "*", | ||||
|     loader: () => redirect("/tool"), | ||||
|   }, | ||||
| ]); | ||||
|  | ||||
| // 路由提供者组件 | ||||
| export const AppRouter = () => <RouterProvider router={router} />; | ||||
		Reference in New Issue
	
	Block a user