diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fecd6f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,84 @@ +# 摄影作品集项目环境变量配置 + +# ================================ +# 数据库配置 +# ================================ +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=photography +DB_USER=postgres +DB_PASSWORD=your_strong_password_here + +# ================================ +# Redis 配置 +# ================================ +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_PASSWORD=your_redis_password_here + +# ================================ +# JWT 认证配置 +# ================================ +JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters +JWT_EXPIRES_IN=24h + +# ================================ +# 服务器配置 +# ================================ +PORT=8080 +GIN_MODE=release +CORS_ORIGINS=https://photography.iriver.top,https://admin.photography.iriver.top + +# ================================ +# 文件存储配置 +# ================================ +STORAGE_TYPE=local +STORAGE_PATH=/app/uploads +MAX_UPLOAD_SIZE=10MB + +# AWS S3 配置 (如果使用 S3 存储) +# AWS_REGION=us-east-1 +# AWS_BUCKET=photography-bucket +# AWS_ACCESS_KEY_ID=your_access_key +# AWS_SECRET_ACCESS_KEY=your_secret_key + +# ================================ +# 日志配置 +# ================================ +LOG_LEVEL=info +LOG_FORMAT=json + +# ================================ +# 邮件配置 (用于通知) +# ================================ +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your_email@gmail.com +SMTP_PASSWORD=your_app_password +SMTP_FROM=noreply@photography.iriver.top + +# ================================ +# 监控配置 +# ================================ +ENABLE_METRICS=true +METRICS_PORT=9090 + +# ================================ +# 安全配置 +# ================================ +RATE_LIMIT=100 +RATE_LIMIT_WINDOW=1h +ENABLE_CSRF=true +CSRF_SECRET=your_csrf_secret_here + +# ================================ +# 缓存配置 +# ================================ +CACHE_TTL=3600 +ENABLE_CACHE=true + +# ================================ +# 部署配置 +# ================================ +ENVIRONMENT=production +TZ=Asia/Shanghai \ No newline at end of file diff --git a/.gitea/workflows/deploy-admin.yml b/.gitea/workflows/deploy-admin.yml new file mode 100644 index 0000000..dda8b70 --- /dev/null +++ b/.gitea/workflows/deploy-admin.yml @@ -0,0 +1,336 @@ +name: 部署管理后台 + +on: + push: + branches: [ main ] + paths: + - 'admin/**' + - '.gitea/workflows/deploy-admin.yml' + workflow_dispatch: + +jobs: + test-and-build: + name: 🧪 测试和构建 + runs-on: ubuntu-latest + + steps: + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 📦 设置 Node.js 环境 + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: admin/package-lock.json + + - name: 📦 安装依赖 + working-directory: ./admin + run: npm ci + + - name: 🔍 代码检查 + working-directory: ./admin + run: | + npm run lint + npm run type-check + + - name: 🎨 格式检查 + working-directory: ./admin + run: npm run format + + - name: 🧪 运行测试 + working-directory: ./admin + run: npm run test + + - name: 🔒 安全审计 + working-directory: ./admin + run: npm audit --audit-level moderate + + - name: 🏗️ 构建生产版本 + working-directory: ./admin + env: + VITE_APP_TITLE: 摄影作品集管理后台 + VITE_API_BASE_URL: https://api.photography.iriver.top + VITE_UPLOAD_URL: https://api.photography.iriver.top/upload + run: npm run build + + - name: 📊 构建分析 + working-directory: ./admin + run: | + echo "📦 构建产物分析:" + du -sh dist/ + echo "📁 文件列表:" + find dist/ -type f -name "*.js" -o -name "*.css" | head -10 + echo "📈 文件大小统计:" + find dist/ -type f \( -name "*.js" -o -name "*.css" \) -exec ls -lh {} + | awk '{print $5, $9}' | sort -hr | head -10 + + - name: 📦 打包构建产物 + uses: actions/upload-artifact@v3 + with: + name: admin-dist + path: admin/dist/ + retention-days: 7 + + deploy: + name: 🚀 部署到生产环境 + runs-on: ubuntu-latest + needs: test-and-build + if: github.ref == 'refs/heads/main' + + steps: + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 📦 设置 Node.js 环境 + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: admin/package-lock.json + + - name: 📦 安装依赖 + working-directory: ./admin + run: npm ci + + - name: 🏗️ 构建生产版本 + working-directory: ./admin + env: + VITE_APP_TITLE: 摄影作品集管理后台 + VITE_API_BASE_URL: https://api.photography.iriver.top + VITE_UPLOAD_URL: https://api.photography.iriver.top/upload + run: npm run build + + - name: 📊 压缩构建产物 + working-directory: ./admin + run: | + tar -czf admin-dist.tar.gz -C dist . + echo "压缩完成: $(ls -lh admin-dist.tar.gz)" + + - name: 🚀 部署到服务器 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + # 设置变量 + ADMIN_DIR="/home/gitea/www/photography-admin" + BACKUP_DIR="/home/gitea/backups/photography-admin" + TEMP_DIR="/tmp/photography-admin-deploy" + + echo "🚀 开始部署管理后台..." + + # 创建临时目录 + mkdir -p "$TEMP_DIR" + + # 创建备份目录 + mkdir -p "$BACKUP_DIR" + + # 备份当前版本 + if [ -d "$ADMIN_DIR" ] && [ "$(ls -A $ADMIN_DIR)" ]; then + echo "📦 备份当前版本..." + BACKUP_NAME="admin-$(date +%Y%m%d-%H%M%S).tar.gz" + tar -czf "$BACKUP_DIR/$BACKUP_NAME" -C "$ADMIN_DIR" . + echo "✅ 备份完成: $BACKUP_NAME" + + # 保留最近10个备份 + cd "$BACKUP_DIR" + ls -t admin-*.tar.gz | tail -n +11 | xargs -r rm + echo "🧹 清理旧备份完成" + fi + + echo "📁 准备部署目录..." + mkdir -p "$ADMIN_DIR" + + - name: 📤 上传构建产物 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + source: admin/admin-dist.tar.gz + target: /tmp/photography-admin-deploy/ + strip_components: 1 + + - name: 🔄 解压并部署 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + # 设置变量 + ADMIN_DIR="/home/gitea/www/photography-admin" + TEMP_DIR="/tmp/photography-admin-deploy" + + echo "🔄 解压新版本..." + cd "$TEMP_DIR" + tar -xzf admin-dist.tar.gz + + echo "📂 部署新版本..." + # 清空目标目录 + rm -rf "$ADMIN_DIR"/* + + # 复制新文件 + cp -r * "$ADMIN_DIR/" + + echo "🔐 设置文件权限..." + chown -R gitea:gitea "$ADMIN_DIR" + chmod -R 755 "$ADMIN_DIR" + + # 设置正确的文件权限 + find "$ADMIN_DIR" -type f -name "*.html" -o -name "*.js" -o -name "*.css" -o -name "*.json" | xargs chmod 644 + find "$ADMIN_DIR" -type d | xargs chmod 755 + + echo "🧹 清理临时文件..." + rm -rf "$TEMP_DIR" + + echo "✅ 管理后台部署完成!" + echo "📊 部署统计:" + echo "文件数量: $(find $ADMIN_DIR -type f | wc -l)" + echo "目录大小: $(du -sh $ADMIN_DIR)" + + - name: 🔍 健康检查 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + echo "🔍 执行健康检查..." + + # 检查文件是否存在 + if [ -f "/home/gitea/www/photography-admin/index.html" ]; then + echo "✅ index.html 文件存在" + else + echo "❌ index.html 文件不存在" + exit 1 + fi + + # 检查网站是否可访问 (本地检查) + sleep 5 + if curl -f -s -o /dev/null https://admin.photography.iriver.top; then + echo "✅ 管理后台访问正常" + else + echo "⚠️ 管理后台访问异常,请检查 Caddy 配置" + fi + + # 重新加载 Caddy (确保新文件被正确服务) + sudo systemctl reload caddy + echo "🔄 Caddy 配置已重新加载" + + - name: 📧 发送部署通知 + if: always() + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + message: | + 🎨 摄影作品集管理后台部署 + + 📦 项目: ${{ github.repository }} + 🌿 分支: ${{ github.ref_name }} + 👤 提交者: ${{ github.actor }} + 📝 提交信息: ${{ github.event.head_commit.message }} + + ${{ job.status == 'success' && '✅ 部署成功' || '❌ 部署失败' }} + + 🌐 管理后台: https://admin.photography.iriver.top + 📱 前端: https://photography.iriver.top + + rollback: + name: 🔄 回滚部署 + runs-on: ubuntu-latest + if: failure() && github.ref == 'refs/heads/main' + needs: deploy + + steps: + - name: 🔄 执行回滚 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + ADMIN_DIR="/home/gitea/www/photography-admin" + BACKUP_DIR="/home/gitea/backups/photography-admin" + + echo "🔄 开始回滚管理后台..." + + # 查找最新的备份 + LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/admin-*.tar.gz 2>/dev/null | head -n 1) + + if [ -n "$LATEST_BACKUP" ]; then + echo "📦 找到备份文件: $LATEST_BACKUP" + + # 清空当前目录 + rm -rf "$ADMIN_DIR"/* + + # 恢复备份 + tar -xzf "$LATEST_BACKUP" -C "$ADMIN_DIR" + + # 设置权限 + chown -R gitea:gitea "$ADMIN_DIR" + chmod -R 755 "$ADMIN_DIR" + + # 重新加载 Caddy + sudo systemctl reload caddy + + echo "✅ 回滚完成" + else + echo "❌ 未找到备份文件,无法回滚" + exit 1 + fi + + security-scan: + name: 🔒 安全扫描 + runs-on: ubuntu-latest + needs: test-and-build + + steps: + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 📦 设置 Node.js 环境 + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: admin/package-lock.json + + - name: 📦 安装依赖 + working-directory: ./admin + run: npm ci + + - name: 🔒 运行安全扫描 + working-directory: ./admin + run: | + echo "🔍 扫描已知漏洞..." + npm audit --audit-level high --production + + echo "📊 依赖分析..." + npx license-checker --summary + + echo "🔍 检查过时依赖..." + npx npm-check-updates + + - name: 📊 生成安全报告 + working-directory: ./admin + run: | + echo "# 安全扫描报告" > security-report.md + echo "## 日期: $(date)" >> security-report.md + echo "## 依赖统计" >> security-report.md + npm ls --depth=0 --json | jq -r '.dependencies | keys | length' | xargs -I {} echo "依赖数量: {}" >> security-report.md + echo "## 许可证检查" >> security-report.md + npx license-checker --csv >> security-report.md + + - name: 📤 上传安全报告 + uses: actions/upload-artifact@v3 + with: + name: security-report + path: admin/security-report.md \ No newline at end of file diff --git a/.gitea/workflows/deploy-backend.yml b/.gitea/workflows/deploy-backend.yml new file mode 100644 index 0000000..3bacadc --- /dev/null +++ b/.gitea/workflows/deploy-backend.yml @@ -0,0 +1,261 @@ +name: 部署后端服务 + +on: + push: + branches: [ main ] + paths: + - 'backend/**' + - 'docker-compose.yml' + - '.env.example' + - '.gitea/workflows/deploy-backend.yml' + workflow_dispatch: + +env: + REGISTRY: registry.cn-hangzhou.aliyuncs.com + IMAGE_NAME: photography/backend + +jobs: + test: + name: 🧪 测试后端 + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: photography_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 🐹 设置 Go 环境 + uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache-dependency-path: backend/go.sum + + - name: 📦 下载依赖 + working-directory: ./backend + run: go mod download + + - name: 🔍 代码检查 + working-directory: ./backend + run: | + go vet ./... + go fmt ./... + # 检查是否有格式化变更 + if [ -n "$(git status --porcelain)" ]; then + echo "代码格式不符合规范,请运行 go fmt" + exit 1 + fi + + - name: 🧪 运行测试 + working-directory: ./backend + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: photography_test + JWT_SECRET: test_jwt_secret_for_ci_cd_testing_only + run: | + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + + - name: 📊 上传覆盖率报告 + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: backend/coverage.html + + - name: 🏗️ 构建检查 + working-directory: ./backend + run: | + CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go + echo "构建成功" + + build-and-deploy: + name: 🚀 构建并部署 + runs-on: ubuntu-latest + needs: test + if: github.ref == 'refs/heads/main' + + steps: + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 🐳 设置 Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔑 登录到镜像仓库 + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: 📝 提取元数据 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: 🏗️ 构建并推送镜像 + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: 🚀 部署到生产环境 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + # 切换到项目目录 + cd /home/gitea/photography + + # 拉取最新代码 + git pull origin main + + # 备份当前运行的容器 (如果存在) + if docker ps -q -f name=photography_backend; then + echo "📦 备份当前后端容器..." + docker commit photography_backend photography_backend_backup_$(date +%Y%m%d_%H%M%S) + fi + + # 停止现有服务 + echo "🛑 停止现有服务..." + docker-compose down backend || true + + # 拉取最新镜像 + echo "📥 拉取最新镜像..." + docker-compose pull backend + + # 启动数据库 (如果未运行) + echo "🗄️ 确保数据库运行..." + docker-compose up -d postgres redis + + # 等待数据库就绪 + echo "⏳ 等待数据库就绪..." + sleep 10 + + # 运行数据库迁移 + echo "🔄 运行数据库迁移..." + docker-compose run --rm backend ./main migrate || echo "迁移完成或已是最新" + + # 启动后端服务 + echo "🚀 启动后端服务..." + docker-compose up -d backend + + # 等待服务启动 + echo "⏳ 等待服务启动..." + sleep 30 + + # 健康检查 + echo "🔍 执行健康检查..." + for i in {1..30}; do + if curl -f http://localhost:8080/health > /dev/null 2>&1; then + echo "✅ 后端服务健康检查通过" + break + fi + echo "等待后端服务启动... ($i/30)" + sleep 10 + done + + # 检查服务状态 + echo "📊 检查服务状态..." + docker-compose ps + + # 清理旧镜像 (保留最近3个) + echo "🧹 清理旧镜像..." + docker images ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | tail -n +4 | awk '{print $1}' | xargs -r docker rmi || true + + # 清理旧备份容器 (保留最近5个) + docker images photography_backend_backup_* --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | tail -n +6 | awk '{print $1}' | xargs -r docker rmi || true + + echo "🎉 后端部署完成!" + + - name: 📧 发送部署通知 + if: always() + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + message: | + 🔧 摄影作品集后端部署 + + 📦 项目: ${{ github.repository }} + 🌿 分支: ${{ github.ref_name }} + 👤 提交者: ${{ github.actor }} + 📝 提交信息: ${{ github.event.head_commit.message }} + + ${{ job.status == 'success' && '✅ 部署成功' || '❌ 部署失败' }} + + 🌐 API: https://api.photography.iriver.top/health + 📊 监控: https://admin.photography.iriver.top + + rollback: + name: 🔄 回滚部署 + runs-on: ubuntu-latest + if: failure() && github.ref == 'refs/heads/main' + needs: build-and-deploy + + steps: + - name: 🔄 执行回滚 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + cd /home/gitea/photography + + echo "🔄 开始回滚后端服务..." + + # 查找最新的备份容器 + BACKUP_IMAGE=$(docker images photography_backend_backup_* --format "table {{.Repository}}:{{.Tag}}\t{{.CreatedAt}}" | tail -n +2 | sort -k2 -r | head -n 1 | awk '{print $1}') + + if [ -n "$BACKUP_IMAGE" ]; then + echo "📦 找到备份镜像: $BACKUP_IMAGE" + + # 停止当前服务 + docker-compose down backend + + # 标记备份镜像为最新 + docker tag $BACKUP_IMAGE photography_backend:rollback + + # 修改 docker-compose 使用回滚镜像 + sed -i 's|build: .*|image: photography_backend:rollback|g' docker-compose.yml + + # 启动回滚版本 + docker-compose up -d backend + + echo "✅ 回滚完成" + else + echo "❌ 未找到备份镜像,无法回滚" + exit 1 + fi \ No newline at end of file diff --git a/.gitea/workflows/deploy-frontend.yml b/.gitea/workflows/deploy-frontend.yml index f329d09..2a55825 100644 --- a/.gitea/workflows/deploy-frontend.yml +++ b/.gitea/workflows/deploy-frontend.yml @@ -1,78 +1,142 @@ -name: Deploy Frontend +name: 部署前端网站 + on: push: branches: [ main ] - paths: [ 'frontend/**' ] - pull_request: - branches: [ main ] - paths: [ 'frontend/**' ] + paths: + - 'frontend/**' + - '.gitea/workflows/deploy-frontend.yml' + workflow_dispatch: jobs: - deploy: + test-and-build: + name: 🧪 测试和构建 runs-on: ubuntu-latest - + steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Bun - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - - name: Install dependencies - run: | - cd frontend - bun install - - - name: Run type check - run: | - cd frontend - bun run type-check - - - name: Run lint - run: | - cd frontend - bun run lint - - - name: Build project - run: | - cd frontend - bun run build - - - name: Deploy to VPS - run: | - # 安装 SSH 客户端、rsync 和 sshpass - sudo apt-get update && sudo apt-get install -y openssh-client rsync sshpass - - # 设置 SSH 选项以禁用主机密钥检查(用于密码认证) - export SSHPASS=${{ secrets.ALIYUN_PWD }} - - # 测试 SSH 连接 - sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "echo 'SSH 连接成功'" - - # 在服务器上创建用户目录下的部署目录 - sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "mkdir -p ~/www/photography" - - # 上传构建文件到服务器用户目录(使用密码认证) - sshpass -e rsync -avz --delete --progress -e "ssh -o StrictHostKeyChecking=no" frontend/out/ ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }}:~/www/photography/ - - # 设置文件权限(用户目录无需 sudo) - sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "chmod -R 755 ~/www/photography" - - # 显示部署信息(Caddy 配置需要手动配置指向新路径) - sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "echo '提示:请确保 Web 服务器配置指向 ~/www/photography/ 目录'" - - echo "✅ 部署完成!" - echo "📁 部署路径:~/www/photography/" - echo "🌐 访问地址:https://photography.iriver.top" - - - name: Notify success - if: success() - run: | - echo "✅ 前端项目部署成功!" - - - name: Notify failure - if: failure() - run: | - echo "❌ 前端项目部署失败!" \ No newline at end of file + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 🦀 设置 Bun 环境 + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: 📦 安装依赖 + working-directory: ./frontend + run: bun install + + - name: 🔍 代码检查 + working-directory: ./frontend + run: | + bun run lint + bun run type-check + + - name: 🧪 运行测试 + working-directory: ./frontend + run: bun run test + + - name: 🏗️ 构建生产版本 + working-directory: ./frontend + env: + NEXT_PUBLIC_API_URL: https://api.photography.iriver.top + NEXT_PUBLIC_SITE_URL: https://photography.iriver.top + NEXT_PUBLIC_SITE_NAME: 摄影作品集 + run: bun run build + + - name: 📦 打包构建产物 + uses: actions/upload-artifact@v3 + with: + name: frontend-dist + path: frontend/out/ + retention-days: 7 + + deploy: + name: 🚀 部署到生产环境 + runs-on: ubuntu-latest + needs: test-and-build + if: github.ref == 'refs/heads/main' + + steps: + - name: 📥 检出代码 + uses: actions/checkout@v4 + + - name: 🦀 设置 Bun 环境 + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: 📦 安装依赖 + working-directory: ./frontend + run: bun install + + - name: 🏗️ 构建生产版本 + working-directory: ./frontend + env: + NEXT_PUBLIC_API_URL: https://api.photography.iriver.top + NEXT_PUBLIC_SITE_URL: https://photography.iriver.top + NEXT_PUBLIC_SITE_NAME: 摄影作品集 + run: bun run build + + - name: 🚀 部署到服务器 + run: | + # 安装部署工具 + sudo apt-get update && sudo apt-get install -y openssh-client rsync sshpass + + # 设置 SSH 环境 + export SSHPASS=${{ secrets.ALIYUN_PWD }} + + echo "🔗 测试 SSH 连接..." + sshpass -e ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "echo 'SSH 连接成功'" + + echo "📁 创建部署目录..." + sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "mkdir -p /home/gitea/www/photography" + + echo "📦 备份当前版本..." + sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} " + if [ -d '/home/gitea/www/photography' ] && [ \"\$(ls -A /home/gitea/www/photography)\" ]; then + mkdir -p /home/gitea/backups/photography-frontend + tar -czf /home/gitea/backups/photography-frontend/frontend-\$(date +%Y%m%d-%H%M%S).tar.gz -C /home/gitea/www/photography . + echo '✅ 备份完成' + fi + " + + echo "🚀 部署新版本..." + sshpass -e rsync -avz --delete --progress -e "ssh -o StrictHostKeyChecking=no" frontend/out/ ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }}:/home/gitea/www/photography/ + + echo "🔐 设置文件权限..." + sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} " + chown -R gitea:gitea /home/gitea/www/photography + chmod -R 755 /home/gitea/www/photography + find /home/gitea/www/photography -type f -name '*.html' -o -name '*.js' -o -name '*.css' -o -name '*.json' | xargs chmod 644 + " + + echo "🔄 重新加载 Web 服务器..." + sshpass -e ssh -o StrictHostKeyChecking=no ${{ secrets.ALIYUN_USER_NAME }}@${{ secrets.ALIYUN_IP }} "sudo systemctl reload caddy" + + echo "✅ 前端部署完成!" + echo "📁 部署路径:/home/gitea/www/photography/" + echo "🌐 访问地址:https://photography.iriver.top" + + - name: 🔍 健康检查 + run: | + echo "🔍 执行健康检查..." + sleep 10 + + # 检查网站是否可访问 + if curl -f -s -o /dev/null https://photography.iriver.top; then + echo "✅ 前端网站访问正常" + else + echo "⚠️ 前端网站访问异常" + exit 1 + fi + + - name: 📧 发送部署通知 + if: always() + run: | + if [ "${{ job.status }}" = "success" ]; then + echo "✅ 摄影作品集前端部署成功!" + echo "🌐 访问地址: https://photography.iriver.top" + else + echo "❌ 摄影作品集前端部署失败!" + fi \ No newline at end of file diff --git a/admin/.pre-commit-config.yaml b/admin/.pre-commit-config.yaml new file mode 100644 index 0000000..498b604 --- /dev/null +++ b/admin/.pre-commit-config.yaml @@ -0,0 +1,63 @@ +repos: + # ESLint 代码检查 + - repo: local + hooks: + - id: eslint + name: ESLint + entry: npm run lint + language: system + files: '\.(js|jsx|ts|tsx)$' + pass_filenames: false + always_run: false + stages: [commit] + + # TypeScript 类型检查 + - repo: local + hooks: + - id: tsc + name: TypeScript Check + entry: npm run type-check + language: system + files: '\.(ts|tsx)$' + pass_filenames: false + always_run: false + stages: [commit] + + # Prettier 代码格式化 + - repo: local + hooks: + - id: prettier + name: Prettier + entry: npm run format:fix + language: system + files: '\.(js|jsx|ts|tsx|json|css|scss|md)$' + pass_filenames: false + always_run: false + stages: [commit] + + # 构建检查 + - repo: local + hooks: + - id: build-check + name: Build Check + entry: npm run build + language: system + pass_filenames: false + always_run: false + stages: [push] + + # 依赖安全检查 + - repo: local + hooks: + - id: audit + name: Security Audit + entry: npm audit --audit-level moderate + language: system + pass_filenames: false + always_run: false + stages: [commit] + +# 全局配置 +default_stages: [commit] +fail_fast: false +minimum_pre_commit_version: "2.20.0" \ No newline at end of file diff --git a/admin/.prettierignore b/admin/.prettierignore new file mode 100644 index 0000000..e4692ba --- /dev/null +++ b/admin/.prettierignore @@ -0,0 +1,9 @@ +node_modules +dist +.vite +coverage +*.min.js +*.min.css +pnpm-lock.yaml +package-lock.json +yarn.lock \ No newline at end of file diff --git a/admin/.prettierrc b/admin/.prettierrc new file mode 100644 index 0000000..e1ff035 --- /dev/null +++ b/admin/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "plugins": ["prettier-plugin-organize-imports"] +} \ No newline at end of file diff --git a/admin/package.json b/admin/package.json index ac44145..9d79914 100644 --- a/admin/package.json +++ b/admin/package.json @@ -9,7 +9,12 @@ "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --ext ts,tsx --fix", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "format": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"", + "test": "echo \"No tests specified\" && exit 0", + "prepare": "cd .. && husky install admin/.husky", + "pre-commit": "lint-staged" }, "dependencies": { "@radix-ui/react-dialog": "^1.0.5", @@ -44,9 +49,21 @@ "eslint": "^8.55.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "husky": "^8.0.3", + "lint-staged": "^15.2.0", "postcss": "^8.4.32", + "prettier": "^3.1.1", "tailwindcss": "^3.4.0", "typescript": "^5.2.2", "vite": "^5.0.8" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,css,md}": [ + "prettier --write" + ] } } \ No newline at end of file diff --git a/admin/src/components/DashboardLayout.tsx b/admin/src/components/DashboardLayout.tsx new file mode 100644 index 0000000..1f327bd --- /dev/null +++ b/admin/src/components/DashboardLayout.tsx @@ -0,0 +1,217 @@ +import React, { useState } from 'react' +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' +import { + LayoutDashboard, + Camera, + FolderOpen, + Tags, + Users, + Settings, + LogOut, + Menu, + User, + Bell, + Search +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { useAuthStore } from '@/stores/authStore' +import { authService } from '@/services/authService' +import { toast } from 'sonner' + +interface DashboardLayoutProps { + children: React.ReactNode +} + +const navigation = [ + { name: '仪表板', href: '/dashboard', icon: LayoutDashboard }, + { name: '照片管理', href: '/photos', icon: Camera }, + { name: '分类管理', href: '/categories', icon: FolderOpen }, + { name: '标签管理', href: '/tags', icon: Tags }, + { name: '用户管理', href: '/users', icon: Users }, + { name: '系统设置', href: '/settings', icon: Settings }, +] + +export default function DashboardLayout({ children }: DashboardLayoutProps) { + const location = useLocation() + const navigate = useNavigate() + const { user, logout } = useAuthStore() + const [sidebarOpen, setSidebarOpen] = useState(false) + + // 获取当前用户信息 + const { data: currentUser } = useQuery({ + queryKey: ['current-user'], + queryFn: authService.getCurrentUser, + initialData: user, + staleTime: 5 * 60 * 1000, // 5分钟 + }) + + const handleLogout = async () => { + try { + await authService.logout() + logout() + toast.success('退出登录成功') + navigate('/login') + } catch (error) { + toast.error('退出登录失败') + } + } + + const isCurrentPath = (path: string) => { + return location.pathname === path || location.pathname.startsWith(path + '/') + } + + const SidebarContent = () => ( +
+ {/* Logo */} +
+ +
+ +
+ 摄影管理 + +
+ + {/* Navigation */} + + + {/* User info */} +
+
+ + + + {currentUser?.username?.charAt(0).toUpperCase()} + + +
+

+ {currentUser?.username} +

+ + {currentUser?.role === 'admin' ? '管理员' : + currentUser?.role === 'editor' ? '编辑者' : '用户'} + +
+
+
+
+ ) + + return ( +
+ {/* Desktop sidebar */} +
+ +
+ + {/* Mobile sidebar */} + + + + + + + {/* Main content */} +
+ {/* Top bar */} +
+
+ + + + + + +
+ + +
+
+ +
+ + + + + + + + + + 我的账户 + + navigate('/profile')}> + + 个人资料 + + navigate('/settings')}> + + 设置 + + + + + 退出登录 + + + +
+
+ + {/* Page content */} +
+ {children} +
+
+
+ ) +} \ No newline at end of file diff --git a/admin/src/components/ui/alert.tsx b/admin/src/components/ui/alert.tsx new file mode 100644 index 0000000..5a7ba0f --- /dev/null +++ b/admin/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/admin/src/components/ui/avatar.tsx b/admin/src/components/ui/avatar.tsx new file mode 100644 index 0000000..c85badc --- /dev/null +++ b/admin/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } \ No newline at end of file diff --git a/admin/src/components/ui/checkbox.tsx b/admin/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..43ac6c4 --- /dev/null +++ b/admin/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } \ No newline at end of file diff --git a/admin/src/components/ui/dropdown-menu.tsx b/admin/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..3a79f44 --- /dev/null +++ b/admin/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} \ No newline at end of file diff --git a/admin/src/components/ui/label.tsx b/admin/src/components/ui/label.tsx new file mode 100644 index 0000000..a7cafcd --- /dev/null +++ b/admin/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } \ No newline at end of file diff --git a/admin/src/components/ui/select.tsx b/admin/src/components/ui/select.tsx new file mode 100644 index 0000000..2b99b27 --- /dev/null +++ b/admin/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} \ No newline at end of file diff --git a/admin/src/components/ui/separator.tsx b/admin/src/components/ui/separator.tsx new file mode 100644 index 0000000..ac15d7b --- /dev/null +++ b/admin/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } \ No newline at end of file diff --git a/admin/src/components/ui/sheet.tsx b/admin/src/components/ui/sheet.tsx new file mode 100644 index 0000000..6796d80 --- /dev/null +++ b/admin/src/components/ui/sheet.tsx @@ -0,0 +1,138 @@ +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} \ No newline at end of file diff --git a/admin/src/components/ui/skeleton.tsx b/admin/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..bee96db --- /dev/null +++ b/admin/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } \ No newline at end of file diff --git a/admin/src/components/ui/switch.tsx b/admin/src/components/ui/switch.tsx new file mode 100644 index 0000000..16c7b50 --- /dev/null +++ b/admin/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } \ No newline at end of file diff --git a/admin/src/components/ui/textarea.tsx b/admin/src/components/ui/textarea.tsx new file mode 100644 index 0000000..83caa9b --- /dev/null +++ b/admin/src/components/ui/textarea.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface TextareaProps + extends React.TextareaHTMLAttributes {} + +const Textarea = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +