Merge pull request #2 from handsomezhuzhu/v0/kdaugh14-4907-781dcc13
feat: implement QR scanning and UI/UX enhancements
94
README.md
@@ -1,59 +1,81 @@
|
|||||||
# Frontend 2FA Tool
|
# 2FA Authenticator / 两步验证器
|
||||||
|
|
||||||
这是一个安全、离线优先的前端双因素认证 (2FA) 工具,基于 Next.js 构建。
|
一个纯前端的 TOTP 两步验证工具,支持多种添加令牌方式,数据完全存储在本地浏览器中。
|
||||||
|
|
||||||
## 功能特性
|
A pure frontend TOTP two-factor authentication tool with multiple token import methods. All data is stored locally in your browser.
|
||||||
|
|
||||||
- 🔒 **安全**: 所有数据存储在本地,不上传服务器
|
## Features / 功能特性
|
||||||
- 📱 **QR 扫码**: 支持直接扫描 QR 码添加令牌 (使用 jsQR)
|
|
||||||
- ⌨️ **手动录入**: 支持手动输入密钥添加
|
|
||||||
- 🌓 **深色模式**: 内置明亮/深色主题切换
|
|
||||||
- 📤 **导入/导出**: 支持令牌数据的备份与恢复
|
|
||||||
- 🌐 **多语言**: 支持国际化
|
|
||||||
|
|
||||||
## 技术栈
|
- **多种添加方式** - 手动输入密钥、扫描二维码、上传二维码图片
|
||||||
|
- **高级设置** - 支持 SHA-1/256/512 算法、6/8位验证码、30/60秒刷新周期
|
||||||
|
- **完整编辑** - 可编辑令牌的所有信息(名称、发行者、密钥、算法等)
|
||||||
|
- **一键复制** - 点击验证码即可复制,带视觉反馈
|
||||||
|
- **数据管理** - 支持导入/导出备份(JSON格式)
|
||||||
|
- **去重检测** - 自动检测重复令牌
|
||||||
|
- **主题切换** - 支持亮色/暗色/跟随系统三种主题
|
||||||
|
- **多语言** - 支持中文/英文切换
|
||||||
|
- **纯前端** - 无需后端,数据存储在浏览器 localStorage
|
||||||
|
- **静态部署** - 支持导出为静态文件部署到任意平台
|
||||||
|
|
||||||
- **框架**: Next.js 14
|
## Tech Stack / 技术栈
|
||||||
- **UI 组件**: Radix UI
|
|
||||||
- **样式**: Tailwind CSS
|
|
||||||
- **工具库**: jsQR, date-fns
|
|
||||||
|
|
||||||
## 环境变量
|
- Next.js 15
|
||||||
|
- React 19
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- shadcn/ui
|
||||||
|
- jsQR (二维码识别)
|
||||||
|
|
||||||
可以在部署时设置以下环境变量来配置页脚信息:
|
## Environment Variables / 环境变量
|
||||||
|
|
||||||
- `NEXT_PUBLIC_SHOW_FOOTER`: 是否显示页脚 (默认: true, 设置为 "false" 隐藏)
|
所有环境变量均为可选配置:
|
||||||
- `NEXT_PUBLIC_FILING_ICP`: ICP 备案号 (例如: 滇ICP备xxxxxxxx号)
|
|
||||||
- `NEXT_PUBLIC_FILING_SECURITY`: 公安联网备案号 (例如: 滇公网安备xxxxxxxxxxxxxx号)
|
|
||||||
|
|
||||||
## 声明
|
| 变量名 | 说明 | 示例值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `NEXT_PUBLIC_SHOW_FOOTER` | 是否显示页脚(默认隐藏) | `true` |
|
||||||
|
| `NEXT_PUBLIC_FOOTER_COPYRIGHT` | 版权所有者名称 | `Your Name` |
|
||||||
|
| `NEXT_PUBLIC_ICP_NUMBER` | ICP备案号(完整文本) | `京ICP备xxxxxxxx号` |
|
||||||
|
| `NEXT_PUBLIC_PSB_NUMBER` | 公安备案号(完整文本) | `京公网安备xxxxxxxxxxxxxx号` |
|
||||||
|
|
||||||
本项目由阿里云ESA提供加速、计算和保护
|
## Deployment / 部署
|
||||||
|
|
||||||

|
### Vercel
|
||||||
|
|
||||||
## 开始使用
|
直接导入 GitHub 仓库即可,无需额外配置。
|
||||||
|
|
||||||
1. 安装依赖:
|
### 静态部署(阿里云 ESA / Cloudflare Pages 等)
|
||||||
|
|
||||||
```bash
|
项目已配置为静态导出模式,构建后会生成 `out` 目录。
|
||||||
pnpm install
|
|
||||||
|
**构建配置:**
|
||||||
|
|
||||||
|
```
|
||||||
|
安装命令:npm install
|
||||||
|
构建命令:npm run build
|
||||||
|
静态资源目录:out
|
||||||
|
Node.js 版本:20.x 或 22.x
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 启动开发服务器:
|
### 本地开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 访问 [http://localhost:3000](http://localhost:3000)
|
## Security / 安全说明
|
||||||
|
|
||||||
## 构建
|
- 所有令牌数据仅存储在浏览器本地 localStorage 中
|
||||||
|
- 不会向任何服务器发送数据
|
||||||
|
- 建议定期导出备份以防数据丢失
|
||||||
|
- 导出的 JSON 文件包含敏感密钥信息,请妥善保管
|
||||||
|
|
||||||
```bash
|
## License / 许可证
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署
|
MIT
|
||||||
|
|
||||||
本项目可以直接部署在阿里云 ESA Pages 上。
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.985 0 0);
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
|
|||||||
185
app/page.tsx
@@ -27,7 +27,6 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Languages,
|
Languages,
|
||||||
Check,
|
Check,
|
||||||
Github,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -51,6 +50,7 @@ import { Toaster } from "@/components/ui/toaster"
|
|||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
import { useTheme } from "@/components/theme-provider"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import { useLanguage } from "@/lib/i18n"
|
import { useLanguage } from "@/lib/i18n"
|
||||||
|
import { TechBackground } from "@/components/tech-background"
|
||||||
|
|
||||||
interface TOTPToken {
|
interface TOTPToken {
|
||||||
id: string
|
id: string
|
||||||
@@ -163,7 +163,6 @@ export default function TwoFactorAuth() {
|
|||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||||
const [editingToken, setEditingToken] = useState<TOTPToken | null>(null)
|
const [editingToken, setEditingToken] = useState<TOTPToken | null>(null)
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -197,7 +196,6 @@ export default function TwoFactorAuth() {
|
|||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
setSettings(JSON.parse(savedSettings))
|
setSettings(JSON.parse(savedSettings))
|
||||||
}
|
}
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Save tokens to localStorage
|
// Save tokens to localStorage
|
||||||
@@ -643,9 +641,10 @@ export default function TwoFactorAuth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen flex flex-col relative">
|
||||||
|
<TechBackground />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b bg-card/50 backdrop-blur-sm sticky top-0 z-50">
|
<header className="border-b bg-background/80 backdrop-blur-sm sticky top-0 z-50">
|
||||||
<div className="max-w-3xl mx-auto px-6 md:px-8 py-4">
|
<div className="max-w-3xl mx-auto px-6 md:px-8 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -658,33 +657,15 @@ export default function TwoFactorAuth() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="icon" asChild>
|
|
||||||
<a
|
|
||||||
href="https://github.com/handsomezhuzhu/2fa-tool"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
title="GitHub"
|
|
||||||
>
|
|
||||||
<Github className="h-5 w-5" />
|
|
||||||
<span className="sr-only">GitHub</span>
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={cycleTheme}
|
onClick={cycleTheme}
|
||||||
title={mounted ? (theme === "light" ? t.themeLight : theme === "dark" ? t.themeDark : t.themeSystem) : t.themeSystem}
|
title={theme === "light" ? t.themeLight : theme === "dark" ? t.themeDark : t.themeSystem}
|
||||||
>
|
>
|
||||||
{!mounted ? (
|
{theme === "light" && <Sun className="h-5 w-5" />}
|
||||||
<Monitor className="h-5 w-5" />
|
{theme === "dark" && <Moon className="h-5 w-5" />}
|
||||||
) : (
|
{theme === "system" && <Monitor className="h-5 w-5" />}
|
||||||
<>
|
|
||||||
{theme === "light" && <Sun className="h-5 w-5" />}
|
|
||||||
{theme === "dark" && <Moon className="h-5 w-5" />}
|
|
||||||
{theme === "system" && <Monitor className="h-5 w-5" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Toggle theme</span>
|
<span className="sr-only">Toggle theme</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -768,7 +749,7 @@ export default function TwoFactorAuth() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1 max-w-3xl mx-auto w-full px-6 md:px-8 py-6">
|
<main className="flex-1 max-w-3xl mx-auto w-full px-6 md:px-8 py-6 relative z-10">
|
||||||
{/* Timer Progress */}
|
{/* Timer Progress */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -1017,7 +998,7 @@ export default function TwoFactorAuth() {
|
|||||||
|
|
||||||
{/* Edit Dialog */}
|
{/* Edit Dialog */}
|
||||||
<Dialog open={!!editingToken} onOpenChange={(open) => !open && setEditingToken(null)}>
|
<Dialog open={!!editingToken} onOpenChange={(open) => !open && setEditingToken(null)}>
|
||||||
<DialogContent>
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t.editToken}</DialogTitle>
|
<DialogTitle>{t.editToken}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -1037,6 +1018,68 @@ export default function TwoFactorAuth() {
|
|||||||
onChange={(e) => setEditingToken({ ...editingToken, issuer: e.target.value })}
|
onChange={(e) => setEditingToken({ ...editingToken, issuer: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t.secretKey}</Label>
|
||||||
|
<Input
|
||||||
|
value={editingToken.secret}
|
||||||
|
onChange={(e) => setEditingToken({ ...editingToken, secret: e.target.value.toUpperCase().replace(/\s/g, "") })}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t.algorithm}</Label>
|
||||||
|
<Select
|
||||||
|
value={editingToken.algorithm}
|
||||||
|
onValueChange={(value: "SHA1" | "SHA256" | "SHA512") =>
|
||||||
|
setEditingToken({ ...editingToken, algorithm: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="SHA1">SHA-1</SelectItem>
|
||||||
|
<SelectItem value="SHA256">SHA-256</SelectItem>
|
||||||
|
<SelectItem value="SHA512">SHA-512</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t.digits}</Label>
|
||||||
|
<Select
|
||||||
|
value={editingToken.digits.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setEditingToken({ ...editingToken, digits: parseInt(value) as 6 | 8 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="6">6</SelectItem>
|
||||||
|
<SelectItem value="8">8</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t.period}</Label>
|
||||||
|
<Select
|
||||||
|
value={editingToken.period.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setEditingToken({ ...editingToken, period: parseInt(value) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="30">30s</SelectItem>
|
||||||
|
<SelectItem value="60">60s</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -1049,47 +1092,46 @@ export default function TwoFactorAuth() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
{/* Footer */}
|
{process.env.NEXT_PUBLIC_SHOW_FOOTER === "true" && (
|
||||||
{(process.env.NEXT_PUBLIC_SHOW_FOOTER !== "false") && (
|
<footer className="border-t py-6 mt-auto relative z-10 bg-background/80 backdrop-blur-sm">
|
||||||
<footer className="border-t py-6 mt-auto">
|
|
||||||
<div className="mx-auto flex max-w-4xl flex-col items-center gap-2 text-center md:flex-row md:justify-between md:gap-4 px-4">
|
<div className="mx-auto flex max-w-4xl flex-col items-center gap-2 text-center md:flex-row md:justify-between md:gap-4 px-4">
|
||||||
<p className="text-xs tracking-wider text-muted-foreground">© 2025 Simon. All rights reserved.</p>
|
<p className="text-xs tracking-wider text-muted-foreground">
|
||||||
{(process.env.NEXT_PUBLIC_FILING_ICP || process.env.NEXT_PUBLIC_FILING_SECURITY) && (
|
© {new Date().getFullYear()} {process.env.NEXT_PUBLIC_FOOTER_COPYRIGHT || ""}. All rights reserved.
|
||||||
<div className="flex items-center gap-4 text-xs tracking-wider text-muted-foreground/60">
|
</p>
|
||||||
{process.env.NEXT_PUBLIC_FILING_ICP && (
|
<div className="flex items-center gap-4 text-xs tracking-wider text-muted-foreground/60">
|
||||||
<a
|
{process.env.NEXT_PUBLIC_ICP_NUMBER && (
|
||||||
href="https://beian.miit.gov.cn/"
|
<a
|
||||||
target="_blank"
|
href="https://beian.miit.gov.cn/"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="transition-colors hover:text-muted-foreground"
|
rel="noopener noreferrer"
|
||||||
>
|
className="transition-colors hover:text-muted-foreground"
|
||||||
{process.env.NEXT_PUBLIC_FILING_ICP}
|
>
|
||||||
</a>
|
{process.env.NEXT_PUBLIC_ICP_NUMBER}
|
||||||
)}
|
</a>
|
||||||
{process.env.NEXT_PUBLIC_FILING_ICP && process.env.NEXT_PUBLIC_FILING_SECURITY && (
|
)}
|
||||||
<span className="text-muted-foreground/30">|</span>
|
{process.env.NEXT_PUBLIC_ICP_NUMBER && process.env.NEXT_PUBLIC_PSB_NUMBER && (
|
||||||
)}
|
<span className="text-muted-foreground/30">|</span>
|
||||||
{process.env.NEXT_PUBLIC_FILING_SECURITY && (
|
)}
|
||||||
<a
|
{process.env.NEXT_PUBLIC_PSB_NUMBER && (
|
||||||
href="https://beian.mps.gov.cn"
|
<a
|
||||||
target="_blank"
|
href="https://beian.mps.gov.cn"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="flex items-center gap-1.5 transition-colors hover:text-muted-foreground"
|
rel="noopener noreferrer"
|
||||||
>
|
className="flex items-center gap-1.5 transition-colors hover:text-muted-foreground"
|
||||||
<img
|
>
|
||||||
alt="公安备案"
|
<img
|
||||||
loading="lazy"
|
alt="公安备案"
|
||||||
width="14"
|
loading="lazy"
|
||||||
height="14"
|
width="14"
|
||||||
decoding="async"
|
height="14"
|
||||||
className="opacity-60"
|
decoding="async"
|
||||||
src="/images/beian.png"
|
className="opacity-60"
|
||||||
/>
|
src="/images/beian.png"
|
||||||
{process.env.NEXT_PUBLIC_FILING_SECURITY}
|
/>
|
||||||
</a>
|
{process.env.NEXT_PUBLIC_PSB_NUMBER}
|
||||||
)}
|
</a>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
)}
|
||||||
@@ -1168,8 +1210,9 @@ function TokenCard({ token, code, timeLeft, showCode, onCopy, onEdit, onDelete,
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={`font-mono text-base font-bold min-w-[90px] text-center transition-colors ${copied ? "text-green-500" : ""
|
className={`font-mono text-base font-bold min-w-[90px] text-center transition-colors ${
|
||||||
}`}
|
copied ? "text-green-500" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{visible ? formattedCode : "••• •••"}
|
{visible ? formattedCode : "••• •••"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
139
components/tech-background.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react"
|
||||||
|
|
||||||
|
export function TechBackground() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const isDark = document.documentElement.classList.contains("dark")
|
||||||
|
|
||||||
|
// Fill with base background color - pure black/white
|
||||||
|
ctx.fillStyle = isDark ? "#0a0a0a" : "#fafafa"
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Draw sophisticated gradient - top left corner glow (grayscale only)
|
||||||
|
const gradient1 = ctx.createRadialGradient(
|
||||||
|
0, 0, 0,
|
||||||
|
0, 0, canvas.width * 0.8
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
gradient1.addColorStop(0, "rgba(255, 255, 255, 0.03)")
|
||||||
|
gradient1.addColorStop(0.5, "rgba(255, 255, 255, 0.015)")
|
||||||
|
gradient1.addColorStop(1, "rgba(0, 0, 0, 0)")
|
||||||
|
} else {
|
||||||
|
gradient1.addColorStop(0, "rgba(0, 0, 0, 0.02)")
|
||||||
|
gradient1.addColorStop(0.5, "rgba(0, 0, 0, 0.01)")
|
||||||
|
gradient1.addColorStop(1, "rgba(255, 255, 255, 0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Draw second gradient - bottom right corner glow (grayscale only)
|
||||||
|
const gradient2 = ctx.createRadialGradient(
|
||||||
|
canvas.width, canvas.height, 0,
|
||||||
|
canvas.width, canvas.height, canvas.width * 0.6
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
gradient2.addColorStop(0, "rgba(255, 255, 255, 0.025)")
|
||||||
|
gradient2.addColorStop(0.5, "rgba(255, 255, 255, 0.01)")
|
||||||
|
gradient2.addColorStop(1, "rgba(0, 0, 0, 0)")
|
||||||
|
} else {
|
||||||
|
gradient2.addColorStop(0, "rgba(0, 0, 0, 0.015)")
|
||||||
|
gradient2.addColorStop(0.5, "rgba(0, 0, 0, 0.008)")
|
||||||
|
gradient2.addColorStop(1, "rgba(255, 255, 255, 0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient2
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Draw subtle grid - grayscale only
|
||||||
|
const gridSize = 60
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
|
||||||
|
// Vertical lines
|
||||||
|
for (let x = 0; x <= canvas.width; x += gridSize) {
|
||||||
|
const lineGradient = ctx.createLinearGradient(x, 0, x, canvas.height)
|
||||||
|
if (isDark) {
|
||||||
|
lineGradient.addColorStop(0, "rgba(255, 255, 255, 0)")
|
||||||
|
lineGradient.addColorStop(0.3, "rgba(255, 255, 255, 0.04)")
|
||||||
|
lineGradient.addColorStop(0.7, "rgba(255, 255, 255, 0.04)")
|
||||||
|
lineGradient.addColorStop(1, "rgba(255, 255, 255, 0)")
|
||||||
|
} else {
|
||||||
|
lineGradient.addColorStop(0, "rgba(0, 0, 0, 0)")
|
||||||
|
lineGradient.addColorStop(0.3, "rgba(0, 0, 0, 0.05)")
|
||||||
|
lineGradient.addColorStop(0.7, "rgba(0, 0, 0, 0.05)")
|
||||||
|
lineGradient.addColorStop(1, "rgba(0, 0, 0, 0)")
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = lineGradient
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x, 0)
|
||||||
|
ctx.lineTo(x, canvas.height)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal lines
|
||||||
|
for (let y = 0; y <= canvas.height; y += gridSize) {
|
||||||
|
const lineGradient = ctx.createLinearGradient(0, y, canvas.width, y)
|
||||||
|
if (isDark) {
|
||||||
|
lineGradient.addColorStop(0, "rgba(255, 255, 255, 0)")
|
||||||
|
lineGradient.addColorStop(0.3, "rgba(255, 255, 255, 0.04)")
|
||||||
|
lineGradient.addColorStop(0.7, "rgba(255, 255, 255, 0.04)")
|
||||||
|
lineGradient.addColorStop(1, "rgba(255, 255, 255, 0)")
|
||||||
|
} else {
|
||||||
|
lineGradient.addColorStop(0, "rgba(0, 0, 0, 0)")
|
||||||
|
lineGradient.addColorStop(0.3, "rgba(0, 0, 0, 0.05)")
|
||||||
|
lineGradient.addColorStop(0.7, "rgba(0, 0, 0, 0.05)")
|
||||||
|
lineGradient.addColorStop(1, "rgba(0, 0, 0, 0)")
|
||||||
|
}
|
||||||
|
ctx.strokeStyle = lineGradient
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(0, y)
|
||||||
|
ctx.lineTo(canvas.width, y)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resize()
|
||||||
|
|
||||||
|
// Observe theme changes
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener("resize", resize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
window.removeEventListener("resize", resize)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="fixed inset-0 pointer-events-none z-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
|
|||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
|
|||||||
@@ -69,6 +69,6 @@
|
|||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tw-animate-css": "1.3.3",
|
"tw-animate-css": "1.3.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1363
pnpm-lock.yaml
generated
BIN
public/apple-icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/icon-dark-32x32.png
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
public/icon-light-32x32.png
Normal file
|
After Width: | Height: | Size: 566 B |
26
public/icon.svg
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.background { fill: black; }
|
||||||
|
.foreground { fill: white; }
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.background { fill: white; }
|
||||||
|
.foreground { fill: black; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g clip-path="url(#clip0_7960_43945)">
|
||||||
|
<rect class="background" width="180" height="180" rx="37" />
|
||||||
|
<g style="transform: scale(95%); transform-origin: center">
|
||||||
|
<path class="foreground"
|
||||||
|
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||||
|
<path class="foreground"
|
||||||
|
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7960_43945">
|
||||||
|
<rect width="180" height="180" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 119 KiB |
BIN
public/placeholder-logo.png
Normal file
|
After Width: | Height: | Size: 568 B |
1
public/placeholder-logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/placeholder-user.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/placeholder.jpg
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
1
public/placeholder.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -1,10 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"target": "ES6",
|
"target": "ES6",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -15,7 +11,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -23,19 +19,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./*"]
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"next-env.d.ts",
|
"exclude": ["node_modules"]
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||