mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 03:26:44 +03:00
🎉 Gitea Mirror: Added
This commit is contained in:
65
.dockerignore
Normal file
65
.dockerignore
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Version control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
pnpm-debug.log
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.*.yml
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
docs
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp
|
||||||
|
temp
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache
|
||||||
|
.npm
|
||||||
|
.pnpm-store
|
||||||
32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Docker Registry Configuration
|
||||||
|
DOCKER_REGISTRY=ghcr.io
|
||||||
|
DOCKER_IMAGE=arunavo4/gitea-mirror
|
||||||
|
DOCKER_TAG=latest
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
NODE_ENV=production
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=4321
|
||||||
|
DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET=change-this-to-a-secure-random-string-in-production
|
||||||
|
|
||||||
|
# Optional GitHub/Gitea Mirror Configuration (for docker-compose, can also be set via web UI)
|
||||||
|
# Uncomment and set as needed. These are passed as environment variables to the container.
|
||||||
|
# GITHUB_USERNAME=your-github-username
|
||||||
|
# GITHUB_TOKEN=your-github-personal-access-token
|
||||||
|
# SKIP_FORKS=false
|
||||||
|
# PRIVATE_REPOSITORIES=false
|
||||||
|
# MIRROR_ISSUES=false
|
||||||
|
# MIRROR_STARRED=false
|
||||||
|
# MIRROR_ORGANIZATIONS=false
|
||||||
|
# PRESERVE_ORG_STRUCTURE=false
|
||||||
|
# ONLY_MIRROR_ORGS=false
|
||||||
|
# SKIP_STARRED_ISSUES=false
|
||||||
|
# GITEA_URL=http://gitea:3000
|
||||||
|
# GITEA_TOKEN=your-local-gitea-token
|
||||||
|
# GITEA_USERNAME=your-local-gitea-username
|
||||||
|
# GITEA_ORGANIZATION=github-mirrors
|
||||||
|
# GITEA_ORG_VISIBILITY=public
|
||||||
|
# DELAY=3600
|
||||||
88
.github/workflows/README.md
vendored
Normal file
88
.github/workflows/README.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# GitHub Workflows for Gitea Mirror
|
||||||
|
|
||||||
|
This directory contains GitHub Actions workflows that automate the build, test, and deployment processes for the Gitea Mirror application.
|
||||||
|
|
||||||
|
## Workflow Overview
|
||||||
|
|
||||||
|
| Workflow | File | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| Astro Build and Test | `astro-build-test.yml` | Builds and tests the Astro application for all branches and PRs |
|
||||||
|
| Docker Build and Push | `docker-build.yml` | Builds and pushes Docker images only for the main branch |
|
||||||
|
| Docker Security Scan | `docker-scan.yml` | Scans Docker images for security vulnerabilities |
|
||||||
|
|
||||||
|
## Workflow Details
|
||||||
|
|
||||||
|
### Astro Build and Test (`astro-build-test.yml`)
|
||||||
|
|
||||||
|
This workflow runs on all branches and pull requests. It:
|
||||||
|
|
||||||
|
- Builds the Astro project
|
||||||
|
- Runs all tests
|
||||||
|
- Uploads build artifacts for potential use in other workflows
|
||||||
|
|
||||||
|
**When it runs:**
|
||||||
|
- On push to any branch (except changes to README.md and docs)
|
||||||
|
- On pull requests to any branch (except changes to README.md and docs)
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- Uses pnpm for faster dependency installation
|
||||||
|
- Caches dependencies to speed up builds
|
||||||
|
- Uploads build artifacts for 7 days
|
||||||
|
|
||||||
|
### Docker Build and Push (`docker-build.yml`)
|
||||||
|
|
||||||
|
This workflow builds and pushes Docker images to GitHub Container Registry (ghcr.io), but only when changes are merged to the main branch.
|
||||||
|
|
||||||
|
**When it runs:**
|
||||||
|
- On push to the main branch
|
||||||
|
- On tag creation (v*)
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- Builds multi-architecture images (amd64 and arm64)
|
||||||
|
- Pushes images only on main branch, not for PRs
|
||||||
|
- Uses build caching to speed up builds
|
||||||
|
- Creates multiple tags for each image (latest, semver, sha)
|
||||||
|
|
||||||
|
### Docker Security Scan (`docker-scan.yml`)
|
||||||
|
|
||||||
|
This workflow scans Docker images for security vulnerabilities using Trivy.
|
||||||
|
|
||||||
|
**When it runs:**
|
||||||
|
- On push to the main branch that affects Docker-related files
|
||||||
|
- Weekly on Sunday at midnight (scheduled)
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- Scans for critical and high severity vulnerabilities
|
||||||
|
- Fails the build if vulnerabilities are found
|
||||||
|
- Ignores unfixed vulnerabilities
|
||||||
|
|
||||||
|
## CI/CD Pipeline Philosophy
|
||||||
|
|
||||||
|
Our CI/CD pipeline follows these principles:
|
||||||
|
|
||||||
|
1. **Fast feedback for developers**: The Astro build and test workflow runs on all branches and PRs to provide quick feedback.
|
||||||
|
2. **Efficient resource usage**: Docker images are only built when changes are merged to main, not for every PR.
|
||||||
|
3. **Security first**: Regular security scanning ensures our Docker images are free from known vulnerabilities.
|
||||||
|
4. **Multi-architecture support**: All Docker images are built for both amd64 and arm64 architectures.
|
||||||
|
|
||||||
|
## Adding or Modifying Workflows
|
||||||
|
|
||||||
|
When adding or modifying workflows:
|
||||||
|
|
||||||
|
1. Ensure the workflow follows the existing patterns
|
||||||
|
2. Test the workflow on a branch before merging to main
|
||||||
|
3. Update this README if you add a new workflow or significantly change an existing one
|
||||||
|
4. Consider the impact on CI resources and build times
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If a workflow fails:
|
||||||
|
|
||||||
|
1. Check the workflow logs in the GitHub Actions tab
|
||||||
|
2. Common issues include:
|
||||||
|
- Test failures
|
||||||
|
- Build errors
|
||||||
|
- Docker build issues
|
||||||
|
- Security vulnerabilities
|
||||||
|
|
||||||
|
For persistent issues, consider opening an issue in the repository.
|
||||||
50
.github/workflows/astro-build-test.yml
vendored
Normal file
50
.github/workflows/astro-build-test.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Astro Build and Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ '*' ]
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- 'docs/**'
|
||||||
|
pull_request:
|
||||||
|
branches: [ '*' ]
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- 'docs/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build and Test Astro Project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 'lts/*'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Build Astro project
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Upload build artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: astro-build
|
||||||
|
path: dist/
|
||||||
|
retention-days: 7
|
||||||
45
.github/workflows/docker-build-stable.yml
vendored
Normal file
45
.github/workflows/docker-build-stable.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
name: Build and Push Docker Images (Stable)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE: ${{ github.repository }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports: ['6379:6379']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }} # Replace with secrets.GHCR_PAT if using PAT
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
|
||||||
53
.github/workflows/docker-scan.yml
vendored
Normal file
53
.github/workflows/docker-scan.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Docker Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile'
|
||||||
|
- '.dockerignore'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * 0' # Run weekly on Sunday at midnight
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
name: Scan Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: gitea-mirror:scan
|
||||||
|
# Disable GitHub Actions cache for this workflow
|
||||||
|
no-cache: true
|
||||||
|
|
||||||
|
- name: Run Trivy vulnerability scanner
|
||||||
|
uses: aquasecurity/trivy-action@master
|
||||||
|
with:
|
||||||
|
image-ref: gitea-mirror:scan
|
||||||
|
format: 'table'
|
||||||
|
exit-code: '1'
|
||||||
|
ignore-unfixed: true
|
||||||
|
vuln-type: 'os,library'
|
||||||
|
severity: 'CRITICAL,HIGH'
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# database files
|
||||||
|
data/gitea-mirror.db
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
88
Dockerfile
Normal file
88
Dockerfile
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# syntax=docker/dockerfile:1.4
|
||||||
|
|
||||||
|
FROM node:lts-alpine AS base
|
||||||
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
# -----------------------------------
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++ gcc
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Full dev install
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# -----------------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++ gcc
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# -----------------------------------
|
||||||
|
FROM deps AS pruner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Prune dev dependencies and just keep the production bits
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||||
|
pnpm prune --prod
|
||||||
|
|
||||||
|
# -----------------------------------
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Only copy production node_modules and built output
|
||||||
|
COPY --from=pruner /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=builder /app/data ./data
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=4321
|
||||||
|
ENV DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||||
|
|
||||||
|
# Make entrypoint executable
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
|
|
||||||
|
RUN apk add --no-cache wget && \
|
||||||
|
mkdir -p /app/data && \
|
||||||
|
addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 gitea-mirror && \
|
||||||
|
chown -R gitea-mirror:nodejs /app/data
|
||||||
|
|
||||||
|
COPY --from=builder --chown=gitea-mirror:nodejs /app/dist ./dist
|
||||||
|
COPY --from=pruner --chown=gitea-mirror:nodejs /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=gitea-mirror:nodejs /app/package.json ./package.json
|
||||||
|
COPY --from=builder --chown=gitea-mirror:nodejs /app/scripts ./scripts
|
||||||
|
|
||||||
|
USER gitea-mirror
|
||||||
|
|
||||||
|
VOLUME /app/data
|
||||||
|
EXPOSE 4321
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:4321/ || exit 1
|
||||||
|
|
||||||
|
# Create a startup script that initializes the database before starting the application
|
||||||
|
COPY --from=builder --chown=gitea-mirror:nodejs /app/docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["./docker-entrypoint.sh"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 ARUNAVO RAY
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
378
README.md
Normal file
378
README.md
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
# Gitea Mirror
|
||||||
|
|
||||||
|
**Gitea Mirror** is a modern web application for automatically mirroring repositories from GitHub to your self-hosted Gitea instance. Designed for developers, teams, and organizations who want to retain full control of their code while still collaborating on GitHub.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔁 Sync public, private, or starred GitHub repos to Gitea
|
||||||
|
- 🏢 Mirror entire organizations with structure preservation
|
||||||
|
- 🐞 Optional mirroring of issues and labels
|
||||||
|
- 🌟 Mirror your starred repositories
|
||||||
|
- 🕹️ Modern user interface with toast notifications and smooth experience
|
||||||
|
- 🧠 Smart filtering and job queue with detailed logs
|
||||||
|
- 🛠️ Works with personal access tokens (GitHub + Gitea)
|
||||||
|
- 🔒 First-time user signup experience with secure authentication
|
||||||
|
- 🐳 Fully Dockerized + can be self-hosted in minutes
|
||||||
|
- 📊 Dashboard with real-time status updates
|
||||||
|
- ⏱️ Scheduled automatic mirroring
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<table> <tr> <td><img src="https://github.com/user-attachments/assets/e62c6560-3da1-4e58-be88-3479e2b3b00b" width="100%"/></td> <td><img src="https://github.com/user-attachments/assets/fd982b81-fae3-4103-bc60-a4d3d5b477dd" width="100%"/></td> </tr> <tr> <td><img src="https://github.com/user-attachments/assets/3f499710-4cfe-45d9-8480-8665ec1efc9e" width="100%"/></td> <td><img src="https://github.com/user-attachments/assets/e032161f-1526-4508-ad13-d91a44458e66" width="100%"/></td> </tr> </table>
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
The dashboard provides an overview of your mirroring status, including total repositories, successfully mirrored repositories, and recent activity.
|
||||||
|
|
||||||
|
### Repository Management
|
||||||
|
Manage all your repositories in one place. Filter by status, search by name, and trigger manual mirroring operations.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
Easily configure your GitHub and Gitea connections, set up automatic mirroring schedules, and manage organization mirroring.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
See the [Quick Start Guide](docs/quickstart.md) for detailed instructions on getting up and running quickly.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18 or later
|
||||||
|
- A GitHub account with a personal access token
|
||||||
|
- A Gitea instance with an access token
|
||||||
|
|
||||||
|
|
||||||
|
#### Database
|
||||||
|
|
||||||
|
The database (`data/gitea-mirror.db`) is created when the application first runs. It starts empty and is populated as you configure and use the application.
|
||||||
|
|
||||||
|
**Note**: On first launch, you'll be guided through creating an admin account with your chosen credentials.
|
||||||
|
|
||||||
|
#### Production Database
|
||||||
|
|
||||||
|
The production database (`data/gitea-mirror.db`) is created when the application runs in production mode. It starts empty and is populated as you configure and use the application.
|
||||||
|
|
||||||
|
**Important**: The production database file is excluded from the Git repository as it may contain sensitive information like GitHub and Gitea tokens. Never commit this file to the repository.
|
||||||
|
|
||||||
|
##### Database Initialization
|
||||||
|
|
||||||
|
Before running the application in production mode for the first time, you need to initialize the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize the database for production mode
|
||||||
|
pnpm setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create the necessary tables. On first launch, you'll be guided through creating your admin account with a secure password.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
#### Using Docker (Recommended)
|
||||||
|
|
||||||
|
Gitea Mirror provides multi-architecture Docker images that work on both ARM64 (e.g., Apple Silicon, Raspberry Pi) and x86_64 (Intel/AMD) platforms.
|
||||||
|
|
||||||
|
##### Using Pre-built Images from GitHub Container Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull the latest multi-architecture image
|
||||||
|
docker pull ghcr.io/arunavo4/gitea-mirror:latest
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
docker run -d \\
|
||||||
|
-p 4321:4321 \\
|
||||||
|
ghcr.io/arunavo4/gitea-mirror:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start the application using Docker Compose
|
||||||
|
docker-compose --profile production up -d
|
||||||
|
|
||||||
|
# For development mode (requires configuration)
|
||||||
|
# Ensure you have run pnpm setup first
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Building Docker Images Manually
|
||||||
|
|
||||||
|
The project includes a build script to create and manage multi-architecture Docker images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy example environment file if you don't have one
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env file with your preferred settings
|
||||||
|
# DOCKER_REGISTRY, DOCKER_IMAGE, DOCKER_TAG, etc.
|
||||||
|
|
||||||
|
# Build and load into local Docker
|
||||||
|
./scripts/build-docker.sh --load
|
||||||
|
|
||||||
|
# OR: Build and push to a registry (requires authentication)
|
||||||
|
./scripts/build-docker.sh --push
|
||||||
|
|
||||||
|
# Then run with Docker Compose
|
||||||
|
docker-compose --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Docker build documentation](./scripts/README-docker.md) for more details.
|
||||||
|
|
||||||
|
##### Building Your Own Image
|
||||||
|
|
||||||
|
For manual Docker builds (without the helper script):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the Docker image for your current architecture
|
||||||
|
docker build -t gitea-mirror:latest .
|
||||||
|
|
||||||
|
# Build multi-architecture images (requires Docker Buildx)
|
||||||
|
docker buildx create --name multiarch --driver docker-container --use
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 -t gitea-mirror:latest --load .
|
||||||
|
|
||||||
|
# If you encounter issues with Buildx, you can try these workarounds:
|
||||||
|
# 1. Retry with network settings
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 -t gitea-mirror:latest --network=host --load .
|
||||||
|
|
||||||
|
# 2. Build one platform at a time if you're having resource issues
|
||||||
|
docker buildx build --platform linux/amd64 -t gitea-mirror:amd64 --load .
|
||||||
|
docker buildx build --platform linux/arm64 -t gitea-mirror:arm64 --load .
|
||||||
|
|
||||||
|
# Create a named volume for database persistence
|
||||||
|
docker volume create gitea-mirror-data
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Environment Variables
|
||||||
|
|
||||||
|
The Docker container can be configured with the following environment variables:
|
||||||
|
|
||||||
|
- `DATABASE_URL`: SQLite database URL (default: `sqlite://data/gitea-mirror.db`)
|
||||||
|
- `HOST`: Host to bind to (default: `0.0.0.0`)
|
||||||
|
- `PORT`: Port to listen on (default: `3000`)
|
||||||
|
- `JWT_SECRET`: Secret key for JWT token generation (important for security)
|
||||||
|
|
||||||
|
|
||||||
|
#### Manual Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||||
|
cd gitea-mirror
|
||||||
|
|
||||||
|
# Quick setup (installs dependencies and initializes the database)
|
||||||
|
pnpm setup
|
||||||
|
|
||||||
|
# Development Mode Options
|
||||||
|
|
||||||
|
# Run in development mode
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Run in development mode with clean database (removes existing DB first)
|
||||||
|
pnpm dev:clean
|
||||||
|
|
||||||
|
# Production Mode Options
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# Preview the production build
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# Start the production server (default)
|
||||||
|
pnpm start
|
||||||
|
|
||||||
|
# Start the production server with a clean setup
|
||||||
|
pnpm start:fresh
|
||||||
|
|
||||||
|
# Database Management
|
||||||
|
|
||||||
|
# Initialize the database
|
||||||
|
pnpm init-db
|
||||||
|
|
||||||
|
# Reset users for testing first-time signup
|
||||||
|
pnpm reset-users
|
||||||
|
|
||||||
|
# Check database status
|
||||||
|
pnpm check-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Gitea Mirror can be configured through environment variables or through the web UI. See the [Configuration Guide](docs/configuration.md) for more details.
|
||||||
|
|
||||||
|
Key configuration options include:
|
||||||
|
|
||||||
|
- GitHub connection settings (username, token, repository filters)
|
||||||
|
- Gitea connection settings (URL, token, organization)
|
||||||
|
- Mirroring options (issues, starred repositories, organizations)
|
||||||
|
- Scheduling options for automatic mirroring
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Gitea Mirror follows a modular architecture with clear separation of concerns. See the [Architecture Document](docs/architecture.md) for a comprehensive overview of the system design, including:
|
||||||
|
|
||||||
|
- Component diagrams
|
||||||
|
- Data flow
|
||||||
|
- Project structure
|
||||||
|
- Key components
|
||||||
|
- Database schema
|
||||||
|
- API endpoints
|
||||||
|
|
||||||
|
### High-Level Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gitea Mirror │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ Frontend │◄───►│ Backend │◄───►│ Database │ │
|
||||||
|
│ │ (Astro) │ │ (Node.js) │ │ (SQLite) │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ └─────────────┘ └──────┬──────┘ └─────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────┼───────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ External APIs │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ GitHub API │ │ Gitea API │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm setup
|
||||||
|
|
||||||
|
# Start the development server
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Up a Local Gitea Instance for Testing
|
||||||
|
|
||||||
|
For full end-to-end testing, you can set up a local Gitea instance using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a Docker network for Gitea and Gitea Mirror to communicate
|
||||||
|
docker network create gitea-network
|
||||||
|
|
||||||
|
# Create volumes for Gitea data persistence
|
||||||
|
docker volume create gitea-data
|
||||||
|
docker volume create gitea-config
|
||||||
|
|
||||||
|
# Run Gitea container
|
||||||
|
docker run -d \
|
||||||
|
--name gitea \
|
||||||
|
--network gitea-network \
|
||||||
|
-p 3001:3000 \
|
||||||
|
-p 2222:22 \
|
||||||
|
-v gitea-data:/data \
|
||||||
|
-v gitea-config:/etc/gitea \
|
||||||
|
-e USER_UID=1000 \
|
||||||
|
-e USER_GID=1000 \
|
||||||
|
-e GITEA__database__DB_TYPE=sqlite3 \
|
||||||
|
-e GITEA__database__PATH=/data/gitea.db \
|
||||||
|
-e GITEA__server__DOMAIN=localhost \
|
||||||
|
-e GITEA__server__ROOT_URL=http://localhost:3001/ \
|
||||||
|
-e GITEA__server__SSH_DOMAIN=localhost \
|
||||||
|
-e GITEA__server__SSH_PORT=2222 \
|
||||||
|
-e GITEA__server__START_SSH_SERVER=true \
|
||||||
|
-e GITEA__security__INSTALL_LOCK=true \
|
||||||
|
-e GITEA__service__DISABLE_REGISTRATION=false \
|
||||||
|
gitea/gitea:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
After Gitea is running:
|
||||||
|
|
||||||
|
1. Access Gitea at http://localhost:3001/
|
||||||
|
2. Register a new user
|
||||||
|
3. Create a personal access token in Gitea (Settings > Applications > Generate New Token)
|
||||||
|
4. Run Gitea Mirror with the local Gitea configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run Gitea Mirror connected to the local Gitea instance
|
||||||
|
docker run -d \
|
||||||
|
--name gitea-mirror-dev \
|
||||||
|
--network gitea-network \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v gitea-mirror-data:/app/data \
|
||||||
|
-e NODE_ENV=development \
|
||||||
|
-e JWT_SECRET=dev-secret-key \
|
||||||
|
-e GITHUB_TOKEN=your-github-token \
|
||||||
|
-e GITHUB_USERNAME=your-github-username \
|
||||||
|
-e GITEA_URL=http://gitea:3000 \
|
||||||
|
-e GITEA_TOKEN=your-local-gitea-token \
|
||||||
|
-e GITEA_USERNAME=your-local-gitea-username \
|
||||||
|
arunavo4/gitea-mirror:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This setup allows you to test the full mirroring functionality with a local Gitea instance.
|
||||||
|
|
||||||
|
### Using Docker Compose for Development
|
||||||
|
|
||||||
|
For convenience, a dedicated development docker-compose file is provided that sets up both Gitea Mirror and a local Gitea instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with development environment and local Gitea instance
|
||||||
|
docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also create a `.env` file with your GitHub and Gitea credentials:
|
||||||
|
|
||||||
|
```
|
||||||
|
# GitHub credentials
|
||||||
|
GITHUB_TOKEN=your-github-token
|
||||||
|
GITHUB_USERNAME=your-github-username
|
||||||
|
|
||||||
|
# Gitea credentials (will be set up after you create a user in the local Gitea instance)
|
||||||
|
GITEA_TOKEN=your-local-gitea-token
|
||||||
|
GITEA_USERNAME=your-local-gitea-username
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technologies Used
|
||||||
|
|
||||||
|
- **Frontend**: Astro, React, Shadcn UI, Tailwind CSS v4
|
||||||
|
- **Backend**: Node.js
|
||||||
|
- **Database**: SQLite (default) or PostgreSQL
|
||||||
|
- **API Integration**: GitHub API (Octokit), Gitea API
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Project Status
|
||||||
|
|
||||||
|
This project is now complete with the following features implemented:
|
||||||
|
|
||||||
|
- ✅ User-friendly dashboard with status overview
|
||||||
|
- ✅ Repository management interface
|
||||||
|
- ✅ Organization management interface
|
||||||
|
- ✅ Configuration management for GitHub and Gitea
|
||||||
|
- ✅ Scheduling and automation
|
||||||
|
- ✅ Activity logging and monitoring
|
||||||
|
- ✅ Responsive design for all screen sizes
|
||||||
|
- ✅ Modern toast notifications for better user feedback
|
||||||
|
- ✅ First-time user signup experience
|
||||||
|
- ✅ Better error handling and user guidance
|
||||||
|
- ✅ Comprehensive error handling
|
||||||
|
- ✅ Unit tests for components and API
|
||||||
|
- ✅ Direct GitHub to Gitea mirroring (no external dependencies)
|
||||||
|
- ✅ Docker and docker-compose support for easy deployment
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
- [Octokit](https://github.com/octokit/rest.js/) - GitHub REST API client for JavaScript
|
||||||
|
- [Shadcn UI](https://ui.shadcn.com/) - For the beautiful UI components
|
||||||
|
- [Astro](https://astro.build/) - For the excellent web framework
|
||||||
17
astro.config.mjs
Normal file
17
astro.config.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'server',
|
||||||
|
adapter: node({
|
||||||
|
mode: 'standalone',
|
||||||
|
}),
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()]
|
||||||
|
},
|
||||||
|
integrations: [react()]
|
||||||
|
});
|
||||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles/global.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
32
data/README.md
Normal file
32
data/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Data Directory
|
||||||
|
|
||||||
|
This directory contains the SQLite database file for the Gitea Mirror application.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `gitea-mirror.db`: The main database file. This file is **not** committed to the repository as it may contain sensitive information like tokens.
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **Never commit `gitea-mirror.db` to the repository** as it may contain sensitive information like GitHub and Gitea tokens.
|
||||||
|
- The application will create this database file automatically on first run.
|
||||||
|
|
||||||
|
## Database Initialization
|
||||||
|
|
||||||
|
To initialize the database for real data mode, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm init-db
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create the necessary tables. On first launch, you'll be guided through creating an admin account with your chosen credentials.
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
To reset users (for testing the first-time setup flow), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm reset-users
|
||||||
|
```
|
||||||
|
|
||||||
|
This will remove all users and their associated data from the database, allowing you to test the signup flow.
|
||||||
111
docker-compose.dev.yml
Normal file
111
docker-compose.dev.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Development environment with local Gitea instance for testing
|
||||||
|
# Run with: docker-compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Local Gitea instance for testing
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
container_name: gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__database__DB_TYPE=sqlite3
|
||||||
|
- GITEA__database__PATH=/data/gitea.db
|
||||||
|
- GITEA__server__DOMAIN=localhost
|
||||||
|
- GITEA__server__ROOT_URL=http://localhost:3001/
|
||||||
|
- GITEA__server__SSH_DOMAIN=localhost
|
||||||
|
- GITEA__server__SSH_PORT=2222
|
||||||
|
- GITEA__server__START_SSH_SERVER=true
|
||||||
|
- GITEA__security__INSTALL_LOCK=true
|
||||||
|
- GITEA__service__DISABLE_REGISTRATION=false
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
- "2222:22"
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
- gitea-config:/etc/gitea
|
||||||
|
networks:
|
||||||
|
- gitea-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Development service connected to local Gitea
|
||||||
|
gitea-mirror-dev:
|
||||||
|
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-arunavo4/gitea-mirror}:${DOCKER_TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
container_name: gitea-mirror-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4321:4321"
|
||||||
|
volumes:
|
||||||
|
- gitea-mirror-data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
- DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=4321
|
||||||
|
- JWT_SECRET=dev-secret-key
|
||||||
|
# GitHub/Gitea Mirror Config
|
||||||
|
- GITHUB_USERNAME=${GITHUB_USERNAME:-your-github-username}
|
||||||
|
- GITHUB_TOKEN=${GITHUB_TOKEN:-your-github-token}
|
||||||
|
- SKIP_FORKS=${SKIP_FORKS:-false}
|
||||||
|
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
|
||||||
|
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
|
||||||
|
- MIRROR_STARRED=${MIRROR_STARRED:-false}
|
||||||
|
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
|
||||||
|
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
|
||||||
|
- ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false}
|
||||||
|
- SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false}
|
||||||
|
- GITEA_URL=http://gitea:3000
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN:-your-local-gitea-token}
|
||||||
|
- GITEA_USERNAME=${GITEA_USERNAME:-your-local-gitea-username}
|
||||||
|
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||||
|
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||||
|
- DELAY=${DELAY:-3600}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4321/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- gitea-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
networks:
|
||||||
|
- gitea-network
|
||||||
|
|
||||||
|
# Define named volumes for data persistence
|
||||||
|
volumes:
|
||||||
|
gitea-data: # Gitea data volume
|
||||||
|
gitea-config: # Gitea config volume
|
||||||
|
gitea-mirror-data: # Gitea Mirror database volume
|
||||||
|
|
||||||
|
redis-data:
|
||||||
|
|
||||||
|
# Define networks
|
||||||
|
networks:
|
||||||
|
gitea-network:
|
||||||
|
name: gitea-network
|
||||||
67
docker-compose.yml
Normal file
67
docker-compose.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Gitea Mirror deployment configuration
|
||||||
|
# - production: Standard deployment with real data
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Production service with real data
|
||||||
|
gitea-mirror:
|
||||||
|
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-gitea-mirror}:${DOCKER_TAG:-latest}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
cache_from:
|
||||||
|
- ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-gitea-mirror}:${DOCKER_TAG:-latest}
|
||||||
|
container_name: gitea-mirror
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "4321:4321"
|
||||||
|
volumes:
|
||||||
|
- gitea-mirror-data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||||
|
- HOST=0.0.0.0
|
||||||
|
- PORT=4321
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-your-secret-key-change-this-in-production}
|
||||||
|
# GitHub/Gitea Mirror Config
|
||||||
|
- GITHUB_USERNAME=${GITHUB_USERNAME:-}
|
||||||
|
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
|
||||||
|
- SKIP_FORKS=${SKIP_FORKS:-false}
|
||||||
|
- PRIVATE_REPOSITORIES=${PRIVATE_REPOSITORIES:-false}
|
||||||
|
- MIRROR_ISSUES=${MIRROR_ISSUES:-false}
|
||||||
|
- MIRROR_STARRED=${MIRROR_STARRED:-false}
|
||||||
|
- MIRROR_ORGANIZATIONS=${MIRROR_ORGANIZATIONS:-false}
|
||||||
|
- PRESERVE_ORG_STRUCTURE=${PRESERVE_ORG_STRUCTURE:-false}
|
||||||
|
- ONLY_MIRROR_ORGS=${ONLY_MIRROR_ORGS:-false}
|
||||||
|
- SKIP_STARRED_ISSUES=${SKIP_STARRED_ISSUES:-false}
|
||||||
|
- GITEA_URL=${GITEA_URL:-}
|
||||||
|
- GITEA_TOKEN=${GITEA_TOKEN:-}
|
||||||
|
- GITEA_USERNAME=${GITEA_USERNAME:-}
|
||||||
|
- GITEA_ORGANIZATION=${GITEA_ORGANIZATION:-github-mirrors}
|
||||||
|
- GITEA_ORG_VISIBILITY=${GITEA_ORG_VISIBILITY:-public}
|
||||||
|
- DELAY=${DELAY:-3600}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=3", "--spider", "http://localhost:4321/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
|
profiles: ["production"]
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
|
||||||
|
# Define named volumes for database persistence
|
||||||
|
volumes:
|
||||||
|
gitea-mirror-data: # Database volume
|
||||||
|
redis-data:
|
||||||
45
docker-entrypoint.sh
Normal file
45
docker-entrypoint.sh
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
mkdir -p /app/data
|
||||||
|
|
||||||
|
# If pnpm is available, run setup (for dev images), else run node init directly
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
echo "Running pnpm setup (if needed)..."
|
||||||
|
pnpm setup || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize the database if it doesn't exist
|
||||||
|
if [ ! -f "/app/data/gitea-mirror.db" ]; then
|
||||||
|
echo "Initializing database..."
|
||||||
|
if [ -f "scripts/init-db.ts" ]; then
|
||||||
|
node -r tsx/cjs scripts/init-db.ts
|
||||||
|
elif [ -f "scripts/manage-db.ts" ]; then
|
||||||
|
node -r tsx/cjs scripts/manage-db.ts init
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Database already exists, checking for issues..."
|
||||||
|
if [ -f "scripts/fix-db-issues.ts" ]; then
|
||||||
|
node -r tsx/cjs scripts/fix-db-issues.ts
|
||||||
|
elif [ -f "scripts/manage-db.ts" ]; then
|
||||||
|
node -r tsx/cjs scripts/manage-db.ts fix
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update the database schema
|
||||||
|
echo "Updating database schema..."
|
||||||
|
if [ -f "scripts/manage-db.ts" ]; then
|
||||||
|
node -r tsx/cjs scripts/manage-db.ts update-schema
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
if [ -f "scripts/run-migrations.ts" ]; then
|
||||||
|
node -r tsx/cjs scripts/run-migrations.ts
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
echo "Starting Gitea Mirror..."
|
||||||
|
exec node ./dist/server/entry.mjs
|
||||||
87
package.json
Normal file
87
package.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "gitea-mirror",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"setup": "pnpm install && pnpm manage-db init",
|
||||||
|
"dev": "astro dev",
|
||||||
|
"dev:clean": "pnpm cleanup-db && pnpm manage-db init && astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"cleanup-db": "rm -f gitea-mirror.db data/gitea-mirror.db",
|
||||||
|
"manage-db": "tsx scripts/manage-db.ts",
|
||||||
|
"init-db": "tsx scripts/manage-db.ts init",
|
||||||
|
"check-db": "tsx scripts/manage-db.ts check",
|
||||||
|
"fix-db": "tsx scripts/manage-db.ts fix",
|
||||||
|
"reset-users": "tsx scripts/manage-db.ts reset-users",
|
||||||
|
"update-schema": "tsx scripts/manage-db.ts update-schema",
|
||||||
|
"db-auto": "tsx scripts/manage-db.ts auto",
|
||||||
|
"run-migrations": "tsx scripts/run-migrations.ts",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"start": "node dist/server/entry.mjs",
|
||||||
|
"start:fresh": "pnpm cleanup-db && pnpm manage-db init && node dist/server/entry.mjs",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/mdx": "^4.2.6",
|
||||||
|
"@astrojs/node": "^9.2.1",
|
||||||
|
"@astrojs/react": "^4.2.7",
|
||||||
|
"@libsql/client": "^0.15.4",
|
||||||
|
"@octokit/rest": "^21.1.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.4",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.5",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.7",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||||
|
"@radix-ui/react-label": "^2.1.6",
|
||||||
|
"@radix-ui/react-popover": "^1.1.13",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.6",
|
||||||
|
"@radix-ui/react-select": "^2.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.4",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.6",
|
||||||
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.8",
|
||||||
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"astro": "^5.7.10",
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"canvas-confetti": "^1.9.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"drizzle-orm": "^0.41.0",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"ioredis": "^5.6.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-react": "^0.488.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"sonner": "^2.0.3",
|
||||||
|
"superagent": "^10.2.0",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"tailwindcss": "^4.1.3",
|
||||||
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"zod": "^3.24.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"@types/superagent": "^8.1.9",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
|
"better-sqlite3": "^9.6.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
|
"vitest": "^3.1.1"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.10.0"
|
||||||
|
}
|
||||||
7713
pnpm-lock.yaml
generated
Normal file
7713
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||||
|
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||||
|
<style>
|
||||||
|
path { fill: #000; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
path { fill: #FFF; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 B |
78
scripts/README-docker.md
Normal file
78
scripts/README-docker.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Scripts Directory
|
||||||
|
|
||||||
|
This directory contains utility scripts for the gitea-mirror project.
|
||||||
|
|
||||||
|
## Docker Build Script
|
||||||
|
|
||||||
|
### build-docker.sh
|
||||||
|
|
||||||
|
This script simplifies the process of building and publishing multi-architecture Docker images for the gitea-mirror project.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-docker.sh [--load] [--push]
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--load`: Load the built image into the local Docker daemon
|
||||||
|
- `--push`: Push the image to the configured Docker registry
|
||||||
|
|
||||||
|
Without any flags, the script will build the image but leave it in the build cache only.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
|
||||||
|
The script uses environment variables from the `.env` file in the project root:
|
||||||
|
|
||||||
|
- `DOCKER_REGISTRY`: The Docker registry to push to (default: ghcr.io)
|
||||||
|
- `DOCKER_IMAGE`: The image name (default: gitea-mirror)
|
||||||
|
- `DOCKER_TAG`: The image tag (default: latest)
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
1. Build for multiple architectures and load into Docker:
|
||||||
|
```bash
|
||||||
|
./scripts/build-docker.sh --load
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build and push to the registry:
|
||||||
|
```bash
|
||||||
|
./scripts/build-docker.sh --push
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Using with docker-compose:
|
||||||
|
```bash
|
||||||
|
# Ensure dependencies are installed and database is initialized
|
||||||
|
pnpm setup
|
||||||
|
|
||||||
|
# First build the image
|
||||||
|
./scripts/build-docker.sh --load
|
||||||
|
|
||||||
|
# Then run using docker-compose for development
|
||||||
|
docker-compose -f ../docker-compose.dev.yml up -d
|
||||||
|
|
||||||
|
# Or for production
|
||||||
|
docker-compose --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagnostics Script
|
||||||
|
|
||||||
|
### docker-diagnostics.sh
|
||||||
|
|
||||||
|
This utility script helps diagnose issues with your Docker setup for building and running Gitea Mirror.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/docker-diagnostics.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks:
|
||||||
|
- Docker and Docker Compose installation
|
||||||
|
- Docker Buildx configuration
|
||||||
|
- QEMU availability for multi-architecture builds
|
||||||
|
- Docker resources (memory, CPU)
|
||||||
|
- Environment configuration
|
||||||
|
- Provides recommendations for building and troubleshooting
|
||||||
|
|
||||||
|
Run this script before building if you're experiencing issues with Docker builds or want to validate your environment.
|
||||||
58
scripts/README.md
Normal file
58
scripts/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Scripts Directory
|
||||||
|
|
||||||
|
This folder contains utility scripts for database management.
|
||||||
|
|
||||||
|
## Database Management Tool (manage-db.ts)
|
||||||
|
|
||||||
|
This is a consolidated database management tool that handles all database-related operations. It combines the functionality of the previous separate scripts into a single, more intelligent script that can check, fix, and initialize the database as needed.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Check Mode**: Validates the existence and integrity of the database
|
||||||
|
- **Init Mode**: Creates the database only if it doesn't already exist
|
||||||
|
- **Fix Mode**: Corrects database file location issues
|
||||||
|
- **Reset Users Mode**: Removes all users and their data
|
||||||
|
- **Auto Mode**: Automatically checks, fixes, and initializes the database if needed
|
||||||
|
|
||||||
|
## Running the Database Management Tool
|
||||||
|
|
||||||
|
You can execute the database management tool using your package manager with various commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Checks database status (default action if no command is specified, equivalent to 'pnpm check-db')
|
||||||
|
pnpm manage-db
|
||||||
|
|
||||||
|
# Check database status
|
||||||
|
pnpm check-db
|
||||||
|
|
||||||
|
# Initialize the database (only if it doesn't exist)
|
||||||
|
pnpm init-db
|
||||||
|
|
||||||
|
# Fix database location issues
|
||||||
|
pnpm fix-db
|
||||||
|
|
||||||
|
# Automatic check, fix, and initialize if needed
|
||||||
|
pnpm db-auto
|
||||||
|
|
||||||
|
# Reset all users (for testing signup flow)
|
||||||
|
pnpm reset-users
|
||||||
|
|
||||||
|
# Update the database schema to the latest version
|
||||||
|
pnpm update-schema
|
||||||
|
|
||||||
|
# Remove database files completely
|
||||||
|
pnpm cleanup-db
|
||||||
|
|
||||||
|
# Complete setup (install dependencies and initialize database)
|
||||||
|
pnpm setup
|
||||||
|
|
||||||
|
# Start development server with a fresh database
|
||||||
|
pnpm dev:clean
|
||||||
|
|
||||||
|
# Start production server with a fresh database
|
||||||
|
pnpm start:fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database File Location
|
||||||
|
|
||||||
|
The database file should be located in the `./data/gitea-mirror.db` directory. If the file is found in the root directory, the fix mode will move it to the correct location.
|
||||||
104
scripts/build-docker.sh
Executable file
104
scripts/build-docker.sh
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build and push the Gitea Mirror docker image for multiple architectures
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
# Load environment variables if .env file exists
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "Loading environment variables from .env"
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set default values if not set in environment
|
||||||
|
DOCKER_REGISTRY=${DOCKER_REGISTRY:-ghcr.io}
|
||||||
|
DOCKER_IMAGE=${DOCKER_IMAGE:-gitea-mirror}
|
||||||
|
DOCKER_TAG=${DOCKER_TAG:-latest}
|
||||||
|
|
||||||
|
FULL_IMAGE_NAME="$DOCKER_REGISTRY/$DOCKER_IMAGE:$DOCKER_TAG"
|
||||||
|
echo "Building image: $FULL_IMAGE_NAME"
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
LOAD=false
|
||||||
|
PUSH=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
key="$1"
|
||||||
|
case $key in
|
||||||
|
--load)
|
||||||
|
LOAD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--push)
|
||||||
|
PUSH=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $key"
|
||||||
|
echo "Usage: $0 [--load] [--push]"
|
||||||
|
echo " --load Load the image into Docker after build"
|
||||||
|
echo " --push Push the image to the registry after build"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build command construction
|
||||||
|
BUILD_CMD="docker buildx build --platform linux/amd64,linux/arm64 -t $FULL_IMAGE_NAME"
|
||||||
|
|
||||||
|
# Add load or push flag if specified
|
||||||
|
if [ "$LOAD" = true ]; then
|
||||||
|
BUILD_CMD="$BUILD_CMD --load"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PUSH" = true ]; then
|
||||||
|
BUILD_CMD="$BUILD_CMD --push"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add context directory
|
||||||
|
BUILD_CMD="$BUILD_CMD ."
|
||||||
|
|
||||||
|
# Execute the build command
|
||||||
|
echo "Executing: $BUILD_CMD"
|
||||||
|
|
||||||
|
# Function to execute with retries
|
||||||
|
execute_with_retry() {
|
||||||
|
local cmd="$1"
|
||||||
|
local max_attempts=${2:-3}
|
||||||
|
local attempt=1
|
||||||
|
local delay=5
|
||||||
|
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
echo "Attempt $attempt of $max_attempts..."
|
||||||
|
if eval "$cmd"; then
|
||||||
|
echo "Command succeeded!"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "Command failed, waiting $delay seconds before retry..."
|
||||||
|
sleep $delay
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
delay=$((delay * 2)) # Exponential backoff
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All attempts failed!"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute with retry
|
||||||
|
execute_with_retry "$BUILD_CMD"
|
||||||
|
BUILD_RESULT=$?
|
||||||
|
|
||||||
|
if [ $BUILD_RESULT -eq 0 ]; then
|
||||||
|
echo "✅ Build successful!"
|
||||||
|
else
|
||||||
|
echo "❌ Build failed after multiple attempts."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Print help message if neither --load nor --push was specified
|
||||||
|
if [ "$LOAD" = false ] && [ "$PUSH" = false ]; then
|
||||||
|
echo
|
||||||
|
echo "NOTE: Image was built but not loaded or pushed. To use this image, run again with:"
|
||||||
|
echo " $0 --load # to load into local Docker"
|
||||||
|
echo " $0 --push # to push to registry $DOCKER_REGISTRY"
|
||||||
|
fi
|
||||||
125
scripts/docker-diagnostics.sh
Executable file
125
scripts/docker-diagnostics.sh
Executable file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Docker setup diagnostics tool for Gitea Mirror
|
||||||
|
|
||||||
|
# ANSI color codes
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}=====================================================${NC}"
|
||||||
|
echo -e "${BLUE} Gitea Mirror Docker Setup Diagnostics ${NC}"
|
||||||
|
echo -e "${BLUE}=====================================================${NC}"
|
||||||
|
|
||||||
|
# Check if Docker is installed and running
|
||||||
|
echo -e "\n${YELLOW}Checking Docker...${NC}"
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
echo -e "${GREEN}✓ Docker is installed${NC}"
|
||||||
|
if docker info &> /dev/null; then
|
||||||
|
echo -e "${GREEN}✓ Docker daemon is running${NC}"
|
||||||
|
|
||||||
|
# Get Docker version
|
||||||
|
DOCKER_VERSION=$(docker version --format '{{.Server.Version}}')
|
||||||
|
echo -e "${GREEN}✓ Docker version: $DOCKER_VERSION${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Docker daemon is not running${NC}"
|
||||||
|
echo -e " Run: ${YELLOW}open -a Docker${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Docker is not installed${NC}"
|
||||||
|
echo -e " Visit: ${BLUE}https://www.docker.com/products/docker-desktop${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Docker Compose
|
||||||
|
echo -e "\n${YELLOW}Checking Docker Compose...${NC}"
|
||||||
|
if docker compose version &> /dev/null; then
|
||||||
|
COMPOSE_VERSION=$(docker compose version --short)
|
||||||
|
echo -e "${GREEN}✓ Docker Compose is installed (v$COMPOSE_VERSION)${NC}"
|
||||||
|
elif command -v docker-compose &> /dev/null; then
|
||||||
|
COMPOSE_VERSION=$(docker-compose --version | awk '{print $3}' | sed 's/,//')
|
||||||
|
echo -e "${GREEN}✓ Docker Compose is installed (v$COMPOSE_VERSION)${NC}"
|
||||||
|
echo -e "${YELLOW}⚠ Using legacy docker-compose - consider upgrading${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Docker Compose is not installed${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Docker Buildx
|
||||||
|
echo -e "\n${YELLOW}Checking Docker Buildx...${NC}"
|
||||||
|
if docker buildx version &> /dev/null; then
|
||||||
|
BUILDX_VERSION=$(docker buildx version | head -n1 | awk '{print $2}')
|
||||||
|
echo -e "${GREEN}✓ Docker Buildx is installed (v$BUILDX_VERSION)${NC}"
|
||||||
|
|
||||||
|
# List available builders
|
||||||
|
echo -e "\n${YELLOW}Available builders:${NC}"
|
||||||
|
docker buildx ls
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Docker Buildx is not installed or not activated${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for QEMU
|
||||||
|
echo -e "\n${YELLOW}Checking QEMU for multi-platform builds...${NC}"
|
||||||
|
if docker run --rm --privileged multiarch/qemu-user-static --reset -p yes &> /dev/null; then
|
||||||
|
echo -e "${GREEN}✓ QEMU is available for multi-architecture builds${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ QEMU setup issue - multi-platform builds may fail${NC}"
|
||||||
|
echo -e " Run: ${YELLOW}docker run --rm --privileged multiarch/qemu-user-static --reset -p yes${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Docker resources
|
||||||
|
echo -e "\n${YELLOW}Checking Docker resources...${NC}"
|
||||||
|
if [ "$(uname)" == "Darwin" ]; then
|
||||||
|
# macOS
|
||||||
|
if command -v osascript &> /dev/null; then
|
||||||
|
SYS_MEM=$(( $(sysctl -n hw.memsize) / 1024 / 1024 / 1024 ))
|
||||||
|
echo -e "System memory: ${GREEN}$SYS_MEM GB${NC}"
|
||||||
|
echo -e "NOTE: Check Docker Desktop settings to see allocated resources"
|
||||||
|
echo -e "Recommended: At least 4GB RAM and 2 CPUs for multi-platform builds"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check environment file
|
||||||
|
echo -e "\n${YELLOW}Checking environment configuration...${NC}"
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo -e "${GREEN}✓ .env file exists${NC}"
|
||||||
|
|
||||||
|
# Parse .env file safely
|
||||||
|
if [ -f .env ]; then
|
||||||
|
REGISTRY=$(grep DOCKER_REGISTRY .env | cut -d= -f2)
|
||||||
|
IMAGE=$(grep DOCKER_IMAGE .env | cut -d= -f2)
|
||||||
|
TAG=$(grep DOCKER_TAG .env | cut -d= -f2)
|
||||||
|
|
||||||
|
echo -e "Docker image configuration:"
|
||||||
|
echo -e " Registry: ${BLUE}${REGISTRY:-"Not set (will use default)"}${NC}"
|
||||||
|
echo -e " Image: ${BLUE}${IMAGE:-"Not set (will use default)"}${NC}"
|
||||||
|
echo -e " Tag: ${BLUE}${TAG:-"Not set (will use default)"}${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ .env file not found${NC}"
|
||||||
|
echo -e " Run: ${YELLOW}cp .env.example .env${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Conclusion and recommendations
|
||||||
|
echo -e "\n${BLUE}=====================================================${NC}"
|
||||||
|
echo -e "${BLUE} Recommendations ${NC}"
|
||||||
|
echo -e "${BLUE}=====================================================${NC}"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}For local development:${NC}"
|
||||||
|
echo -e "1. ${GREEN}pnpm setup${NC} (initialize database and install dependencies)"
|
||||||
|
echo -e "2. ${GREEN}./scripts/build-docker.sh --load${NC} (build and load into Docker)"
|
||||||
|
echo -e "3. ${GREEN}docker-compose -f docker-compose.dev.yml up -d${NC} (start the development container)"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}For production deployment (using Docker Compose):${NC}"
|
||||||
|
echo -e "1. ${GREEN}pnpm setup${NC} (if not already done, to ensure database schema is ready)"
|
||||||
|
echo -e "2. ${GREEN}docker-compose --profile production up -d${NC} (start the production container)"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}For CI/CD builds:${NC}"
|
||||||
|
echo -e "1. Use GitHub Actions workflow with retry mechanism"
|
||||||
|
echo -e "2. If build fails, try running with: ${GREEN}DOCKER_BUILDKIT=1${NC}"
|
||||||
|
echo -e "3. Consider breaking the build into multiple steps for better reliability"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}For troubleshooting:${NC}"
|
||||||
|
echo -e "1. Check container logs: ${GREEN}docker logs gitea-mirror-dev${NC} (for development) or ${GREEN}docker logs gitea-mirror${NC} (for production)"
|
||||||
|
echo -e "2. Check health status: ${GREEN}docker inspect --format='{{.State.Health.Status}}' gitea-mirror-dev${NC} (for development) or ${GREEN}docker inspect --format='{{.State.Health.Status}}' gitea-mirror${NC} (for production)"
|
||||||
|
echo -e "3. See full documentation: ${BLUE}.github/workflows/TROUBLESHOOTING.md${NC}"
|
||||||
|
echo -e ""
|
||||||
803
scripts/manage-db.ts
Normal file
803
scripts/manage-db.ts
Normal file
@@ -0,0 +1,803 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { client, db } from "../src/lib/db";
|
||||||
|
import { configs } from "../src/lib/db";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
// Command line arguments
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0] || "check";
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = path.join(process.cwd(), "data");
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database paths
|
||||||
|
const rootDbFile = path.join(process.cwd(), "gitea-mirror.db");
|
||||||
|
const rootDevDbFile = path.join(process.cwd(), "gitea-mirror-dev.db");
|
||||||
|
const dataDbFile = path.join(dataDir, "gitea-mirror.db");
|
||||||
|
const dataDevDbFile = path.join(dataDir, "gitea-mirror-dev.db");
|
||||||
|
|
||||||
|
// Database path - ensure we use absolute path
|
||||||
|
const dbPath =
|
||||||
|
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure all required tables exist
|
||||||
|
*/
|
||||||
|
async function ensureTablesExist() {
|
||||||
|
const requiredTables = [
|
||||||
|
"users",
|
||||||
|
"configs",
|
||||||
|
"repositories",
|
||||||
|
"organizations",
|
||||||
|
"mirror_jobs",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const table of requiredTables) {
|
||||||
|
try {
|
||||||
|
await client.execute(`SELECT 1 FROM ${table} LIMIT 1`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("SQLITE_ERROR")) {
|
||||||
|
console.warn(`⚠️ Table '${table}' is missing. Creating it now...`);
|
||||||
|
switch (table) {
|
||||||
|
case "users":
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "configs":
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE configs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
github_config TEXT NOT NULL,
|
||||||
|
gitea_config TEXT NOT NULL,
|
||||||
|
include TEXT NOT NULL DEFAULT '[]',
|
||||||
|
exclude TEXT NOT NULL DEFAULT '[]',
|
||||||
|
schedule_config TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "repositories":
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE repositories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
clone_url TEXT NOT NULL,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
organization TEXT,
|
||||||
|
is_private INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_fork INTEGER NOT NULL DEFAULT 0,
|
||||||
|
forked_from TEXT,
|
||||||
|
has_issues INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_lfs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_submodules INTEGER NOT NULL DEFAULT 0,
|
||||||
|
default_branch TEXT NOT NULL,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
status TEXT NOT NULL DEFAULT 'imported',
|
||||||
|
last_mirrored INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "organizations":
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT NOT NULL,
|
||||||
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
is_included INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'imported',
|
||||||
|
last_mirrored INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
repository_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "mirror_jobs":
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE mirror_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
repository_id TEXT,
|
||||||
|
repository_name TEXT,
|
||||||
|
organization_id TEXT,
|
||||||
|
organization_name TEXT,
|
||||||
|
details TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'imported',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
console.log(`✅ Table '${table}' created successfully.`);
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Error checking table '${table}':`, error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check database status
|
||||||
|
*/
|
||||||
|
async function checkDatabase() {
|
||||||
|
console.log("Checking database status...");
|
||||||
|
|
||||||
|
// Check for database files in the root directory (which is incorrect)
|
||||||
|
if (fs.existsSync(rootDbFile)) {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ WARNING: Database file found in root directory: gitea-mirror.db"
|
||||||
|
);
|
||||||
|
console.warn(" This file should be in the data directory.");
|
||||||
|
console.warn(
|
||||||
|
' Run "pnpm manage-db fix" to fix this issue or "pnpm cleanup-db" to remove it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if database files exist in the data directory (which is correct)
|
||||||
|
if (fs.existsSync(dataDbFile)) {
|
||||||
|
console.log(
|
||||||
|
"✅ Database file found in data directory: data/gitea-mirror.db"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for users
|
||||||
|
try {
|
||||||
|
const userCountResult = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM users`
|
||||||
|
);
|
||||||
|
const userCount = userCountResult.rows[0].count;
|
||||||
|
|
||||||
|
if (userCount === 0) {
|
||||||
|
console.log("ℹ️ No users found in the database.");
|
||||||
|
console.log(
|
||||||
|
" When you start the application, you will be directed to the signup page"
|
||||||
|
);
|
||||||
|
console.log(" to create an initial admin account.");
|
||||||
|
} else {
|
||||||
|
console.log(`✅ ${userCount} user(s) found in the database.`);
|
||||||
|
console.log(" The application will show the login page on startup.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for configurations
|
||||||
|
const configCountResult = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM configs`
|
||||||
|
);
|
||||||
|
const configCount = configCountResult.rows[0].count;
|
||||||
|
|
||||||
|
if (configCount === 0) {
|
||||||
|
console.log("ℹ️ No configurations found in the database.");
|
||||||
|
console.log(
|
||||||
|
" You will need to set up your GitHub and Gitea configurations after login."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`✅ ${configCount} configuration(s) found in the database.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error connecting to the database:", error);
|
||||||
|
console.warn(
|
||||||
|
' The database file might be corrupted. Consider running "pnpm manage-db init" to recreate it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ WARNING: Database file not found in data directory.");
|
||||||
|
console.warn(' Run "pnpm manage-db init" to create it.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update database schema
|
||||||
|
*/
|
||||||
|
async function updateSchema() {
|
||||||
|
console.log(`Checking and updating database schema at ${dbPath}...`);
|
||||||
|
|
||||||
|
// Check if the database exists
|
||||||
|
if (!fs.existsSync(dataDbFile)) {
|
||||||
|
console.log(
|
||||||
|
"⚠️ Database file doesn't exist. Run 'pnpm manage-db init' first to create it."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Checking for missing columns in mirror_jobs table...");
|
||||||
|
|
||||||
|
// Check if repository_id column exists in mirror_jobs table
|
||||||
|
const tableInfoResult = await client.execute(
|
||||||
|
`PRAGMA table_info(mirror_jobs)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get column names
|
||||||
|
const columns = tableInfoResult.rows.map((row) => row.name);
|
||||||
|
|
||||||
|
// Check for repository_id column
|
||||||
|
if (!columns.includes("repository_id")) {
|
||||||
|
console.log(
|
||||||
|
"Adding missing repository_id column to mirror_jobs table..."
|
||||||
|
);
|
||||||
|
await client.execute(
|
||||||
|
`ALTER TABLE mirror_jobs ADD COLUMN repository_id TEXT;`
|
||||||
|
);
|
||||||
|
console.log("✅ Added repository_id column to mirror_jobs table.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for repository_name column
|
||||||
|
if (!columns.includes("repository_name")) {
|
||||||
|
console.log(
|
||||||
|
"Adding missing repository_name column to mirror_jobs table..."
|
||||||
|
);
|
||||||
|
await client.execute(
|
||||||
|
`ALTER TABLE mirror_jobs ADD COLUMN repository_name TEXT;`
|
||||||
|
);
|
||||||
|
console.log("✅ Added repository_name column to mirror_jobs table.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for organization_id column
|
||||||
|
if (!columns.includes("organization_id")) {
|
||||||
|
console.log(
|
||||||
|
"Adding missing organization_id column to mirror_jobs table..."
|
||||||
|
);
|
||||||
|
await client.execute(
|
||||||
|
`ALTER TABLE mirror_jobs ADD COLUMN organization_id TEXT;`
|
||||||
|
);
|
||||||
|
console.log("✅ Added organization_id column to mirror_jobs table.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for organization_name column
|
||||||
|
if (!columns.includes("organization_name")) {
|
||||||
|
console.log(
|
||||||
|
"Adding missing organization_name column to mirror_jobs table..."
|
||||||
|
);
|
||||||
|
await client.execute(
|
||||||
|
`ALTER TABLE mirror_jobs ADD COLUMN organization_name TEXT;`
|
||||||
|
);
|
||||||
|
console.log("✅ Added organization_name column to mirror_jobs table.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for details column
|
||||||
|
if (!columns.includes("details")) {
|
||||||
|
console.log("Adding missing details column to mirror_jobs table...");
|
||||||
|
await client.execute(`ALTER TABLE mirror_jobs ADD COLUMN details TEXT;`);
|
||||||
|
console.log("✅ Added details column to mirror_jobs table.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mirrored_location column in repositories table
|
||||||
|
const repoColumns = await client.execute(
|
||||||
|
`PRAGMA table_info(repositories)`
|
||||||
|
);
|
||||||
|
const repoColumnNames = repoColumns.rows.map((row: any) => row.name);
|
||||||
|
|
||||||
|
if (!repoColumnNames.includes("mirrored_location")) {
|
||||||
|
console.log("Adding missing mirrored_location column to repositories table...");
|
||||||
|
await client.execute(
|
||||||
|
`ALTER TABLE repositories ADD COLUMN mirrored_location TEXT DEFAULT '';`
|
||||||
|
);
|
||||||
|
console.log("✅ Added mirrored_location column to repositories table.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Schema update completed successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error updating schema:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database
|
||||||
|
*/
|
||||||
|
async function initializeDatabase() {
|
||||||
|
// Check if database already exists first
|
||||||
|
if (fs.existsSync(dataDbFile)) {
|
||||||
|
console.log("⚠️ Database already exists at data/gitea-mirror.db");
|
||||||
|
console.log(
|
||||||
|
' If you want to recreate the database, run "pnpm cleanup-db" first.'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
' Or use "pnpm manage-db reset-users" to just remove users without recreating tables.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we can connect to it
|
||||||
|
try {
|
||||||
|
await client.execute(`SELECT COUNT(*) as count FROM users`);
|
||||||
|
console.log("✅ Database is valid and accessible.");
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error connecting to the existing database:", error);
|
||||||
|
console.log(
|
||||||
|
" The database might be corrupted. Proceeding with reinitialization..."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Initializing database at ${dbPath}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create tables if they don't exist
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOTE: We no longer create a default admin user - user will create one via signup page
|
||||||
|
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS configs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
github_config TEXT NOT NULL,
|
||||||
|
gitea_config TEXT NOT NULL,
|
||||||
|
include TEXT NOT NULL DEFAULT '["*"]',
|
||||||
|
exclude TEXT NOT NULL DEFAULT '[]',
|
||||||
|
schedule_config TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS repositories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
clone_url TEXT NOT NULL,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
organization TEXT,
|
||||||
|
mirrored_location TEXT DEFAULT '',
|
||||||
|
|
||||||
|
is_private INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_fork INTEGER NOT NULL DEFAULT 0,
|
||||||
|
forked_from TEXT,
|
||||||
|
|
||||||
|
has_issues INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_lfs INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_submodules INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
default_branch TEXT NOT NULL,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'imported',
|
||||||
|
last_mirrored INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
|
||||||
|
avatar_url TEXT NOT NULL,
|
||||||
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
|
||||||
|
is_included INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
status TEXT NOT NULL DEFAULT 'imported',
|
||||||
|
last_mirrored INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
|
||||||
|
repository_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs(id)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.execute(
|
||||||
|
`CREATE TABLE IF NOT EXISTS mirror_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
repository_id TEXT,
|
||||||
|
repository_name TEXT,
|
||||||
|
organization_id TEXT,
|
||||||
|
organization_name TEXT,
|
||||||
|
details TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'imported',
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert default config if none exists
|
||||||
|
const configCountResult = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM configs`
|
||||||
|
);
|
||||||
|
const configCount = configCountResult.rows[0].count;
|
||||||
|
if (configCount === 0) {
|
||||||
|
// Get the first user
|
||||||
|
const firstUserResult = await client.execute(
|
||||||
|
`SELECT id FROM users LIMIT 1`
|
||||||
|
);
|
||||||
|
if (firstUserResult.rows.length > 0) {
|
||||||
|
const userId = firstUserResult.rows[0].id;
|
||||||
|
const configId = uuidv4();
|
||||||
|
const githubConfig = JSON.stringify({
|
||||||
|
username: process.env.GITHUB_USERNAME || "",
|
||||||
|
token: process.env.GITHUB_TOKEN || "",
|
||||||
|
skipForks: false,
|
||||||
|
privateRepositories: false,
|
||||||
|
mirrorIssues: false,
|
||||||
|
mirrorStarred: true,
|
||||||
|
useSpecificUser: false,
|
||||||
|
preserveOrgStructure: true,
|
||||||
|
skipStarredIssues: false,
|
||||||
|
});
|
||||||
|
const giteaConfig = JSON.stringify({
|
||||||
|
url: process.env.GITEA_URL || "",
|
||||||
|
token: process.env.GITEA_TOKEN || "",
|
||||||
|
username: process.env.GITEA_USERNAME || "",
|
||||||
|
organization: "",
|
||||||
|
visibility: "public",
|
||||||
|
starredReposOrg: "github",
|
||||||
|
});
|
||||||
|
const include = JSON.stringify(["*"]);
|
||||||
|
const exclude = JSON.stringify([]);
|
||||||
|
const scheduleConfig = JSON.stringify({
|
||||||
|
enabled: false,
|
||||||
|
interval: 3600,
|
||||||
|
lastRun: null,
|
||||||
|
nextRun: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.execute(
|
||||||
|
`
|
||||||
|
INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, include, exclude, schedule_config, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
configId,
|
||||||
|
userId,
|
||||||
|
"Default Configuration",
|
||||||
|
1,
|
||||||
|
githubConfig,
|
||||||
|
giteaConfig,
|
||||||
|
include,
|
||||||
|
exclude,
|
||||||
|
scheduleConfig,
|
||||||
|
Date.now(),
|
||||||
|
Date.now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Database initialization completed successfully.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error initializing database:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset users in the database
|
||||||
|
*/
|
||||||
|
async function resetUsers() {
|
||||||
|
console.log(`Resetting users in database at ${dbPath}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if the database exists
|
||||||
|
const dbFilePath = dbPath.replace("file:", "");
|
||||||
|
const doesDbExist = fs.existsSync(dbFilePath);
|
||||||
|
|
||||||
|
if (!doesDbExist) {
|
||||||
|
console.log(
|
||||||
|
"❌ Database file doesn't exist. Run 'pnpm manage-db init' first to create it."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count existing users
|
||||||
|
const userCountResult = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM users`
|
||||||
|
);
|
||||||
|
const userCount = userCountResult.rows[0].count;
|
||||||
|
|
||||||
|
if (userCount === 0) {
|
||||||
|
console.log("ℹ️ No users found in the database. Nothing to reset.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all users
|
||||||
|
await client.execute(`DELETE FROM users`);
|
||||||
|
console.log(`✅ Deleted ${userCount} users from the database.`);
|
||||||
|
|
||||||
|
// Check dependent configurations that need to be removed
|
||||||
|
const configCount = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM configs`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
configCount.rows &&
|
||||||
|
configCount.rows[0] &&
|
||||||
|
Number(configCount.rows[0].count) > 0
|
||||||
|
) {
|
||||||
|
await client.execute(`DELETE FROM configs`);
|
||||||
|
console.log(`✅ Deleted ${configCount.rows[0].count} configurations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dependent repositories
|
||||||
|
const repoCount = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM repositories`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
repoCount.rows &&
|
||||||
|
repoCount.rows[0] &&
|
||||||
|
Number(repoCount.rows[0].count) > 0
|
||||||
|
) {
|
||||||
|
await client.execute(`DELETE FROM repositories`);
|
||||||
|
console.log(`✅ Deleted ${repoCount.rows[0].count} repositories.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dependent organizations
|
||||||
|
const orgCount = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM organizations`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
orgCount.rows &&
|
||||||
|
orgCount.rows[0] &&
|
||||||
|
Number(orgCount.rows[0].count) > 0
|
||||||
|
) {
|
||||||
|
await client.execute(`DELETE FROM organizations`);
|
||||||
|
console.log(`✅ Deleted ${orgCount.rows[0].count} organizations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dependent mirror jobs
|
||||||
|
const jobCount = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM mirror_jobs`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
jobCount.rows &&
|
||||||
|
jobCount.rows[0] &&
|
||||||
|
Number(jobCount.rows[0].count) > 0
|
||||||
|
) {
|
||||||
|
await client.execute(`DELETE FROM mirror_jobs`);
|
||||||
|
console.log(`✅ Deleted ${jobCount.rows[0].count} mirror jobs.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"✅ Database has been reset. The application will now prompt for a new admin account setup on next run."
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error resetting users:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix database location issues
|
||||||
|
*/
|
||||||
|
async function fixDatabaseIssues() {
|
||||||
|
console.log("Checking for database issues...");
|
||||||
|
|
||||||
|
// Check for database files in the root directory
|
||||||
|
if (fs.existsSync(rootDbFile)) {
|
||||||
|
console.log("Found database file in root directory: gitea-mirror.db");
|
||||||
|
|
||||||
|
// If the data directory doesn't have the file, move it there
|
||||||
|
if (!fs.existsSync(dataDbFile)) {
|
||||||
|
console.log("Moving database file to data directory...");
|
||||||
|
fs.copyFileSync(rootDbFile, dataDbFile);
|
||||||
|
console.log("Database file moved successfully.");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Database file already exists in data directory. Checking for differences..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compare file sizes to see which is newer/larger
|
||||||
|
const rootStats = fs.statSync(rootDbFile);
|
||||||
|
const dataStats = fs.statSync(dataDbFile);
|
||||||
|
|
||||||
|
if (
|
||||||
|
rootStats.size > dataStats.size ||
|
||||||
|
rootStats.mtime > dataStats.mtime
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"Root database file is newer or larger. Backing up data directory file and replacing it..."
|
||||||
|
);
|
||||||
|
fs.copyFileSync(dataDbFile, `${dataDbFile}.backup-${Date.now()}`);
|
||||||
|
fs.copyFileSync(rootDbFile, dataDbFile);
|
||||||
|
console.log("Database file replaced successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the root file
|
||||||
|
console.log("Removing database file from root directory...");
|
||||||
|
fs.unlinkSync(rootDbFile);
|
||||||
|
console.log("Root database file removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the same for dev database
|
||||||
|
if (fs.existsSync(rootDevDbFile)) {
|
||||||
|
console.log(
|
||||||
|
"Found development database file in root directory: gitea-mirror-dev.db"
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the data directory doesn't have the file, move it there
|
||||||
|
if (!fs.existsSync(dataDevDbFile)) {
|
||||||
|
console.log("Moving development database file to data directory...");
|
||||||
|
fs.copyFileSync(rootDevDbFile, dataDevDbFile);
|
||||||
|
console.log("Development database file moved successfully.");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"Development database file already exists in data directory. Checking for differences..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compare file sizes to see which is newer/larger
|
||||||
|
const rootStats = fs.statSync(rootDevDbFile);
|
||||||
|
const dataStats = fs.statSync(dataDevDbFile);
|
||||||
|
|
||||||
|
if (
|
||||||
|
rootStats.size > dataStats.size ||
|
||||||
|
rootStats.mtime > dataStats.mtime
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"Root development database file is newer or larger. Backing up data directory file and replacing it..."
|
||||||
|
);
|
||||||
|
fs.copyFileSync(dataDevDbFile, `${dataDevDbFile}.backup-${Date.now()}`);
|
||||||
|
fs.copyFileSync(rootDevDbFile, dataDevDbFile);
|
||||||
|
console.log("Development database file replaced successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the root file
|
||||||
|
console.log("Removing development database file from root directory...");
|
||||||
|
fs.unlinkSync(rootDevDbFile);
|
||||||
|
console.log("Root development database file removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if database files exist in the data directory
|
||||||
|
if (!fs.existsSync(dataDbFile)) {
|
||||||
|
console.warn(
|
||||||
|
"⚠️ WARNING: Production database file not found in data directory."
|
||||||
|
);
|
||||||
|
console.warn(' Run "pnpm manage-db init" to create it.');
|
||||||
|
} else {
|
||||||
|
console.log("✅ Production database file found in data directory.");
|
||||||
|
|
||||||
|
// Check if we can connect to the database
|
||||||
|
try {
|
||||||
|
// Try to query the database
|
||||||
|
const configCount = await db.select().from(configs).limit(1);
|
||||||
|
console.log(`✅ Successfully connected to the database.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error connecting to the database:", error);
|
||||||
|
console.warn(
|
||||||
|
' The database file might be corrupted. Consider running "pnpm manage-db init" to recreate it.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Database check completed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to handle the command
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log(`Database Management Tool for Gitea Mirror`);
|
||||||
|
|
||||||
|
// Ensure all required tables exist
|
||||||
|
console.log("Ensuring all required tables exist...");
|
||||||
|
await ensureTablesExist();
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "check":
|
||||||
|
await checkDatabase();
|
||||||
|
break;
|
||||||
|
case "init":
|
||||||
|
await initializeDatabase();
|
||||||
|
break;
|
||||||
|
case "fix":
|
||||||
|
await fixDatabaseIssues();
|
||||||
|
break;
|
||||||
|
case "reset-users":
|
||||||
|
await resetUsers();
|
||||||
|
break;
|
||||||
|
case "update-schema":
|
||||||
|
await updateSchema();
|
||||||
|
break;
|
||||||
|
case "auto":
|
||||||
|
// Auto mode: check, fix, and initialize if needed
|
||||||
|
console.log("Running in auto mode: check, fix, and initialize if needed");
|
||||||
|
await fixDatabaseIssues();
|
||||||
|
|
||||||
|
// Also update schema in auto mode
|
||||||
|
await updateSchema();
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataDbFile)) {
|
||||||
|
await initializeDatabase();
|
||||||
|
} else {
|
||||||
|
await checkDatabase();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`
|
||||||
|
Available commands:
|
||||||
|
check - Check database status
|
||||||
|
init - Initialize the database (only if it doesn't exist)
|
||||||
|
fix - Fix database location issues
|
||||||
|
reset-users - Remove all users and their data
|
||||||
|
update-schema - Update the database schema to the latest version
|
||||||
|
auto - Automatic mode: check, fix, and initialize if needed
|
||||||
|
|
||||||
|
Usage: pnpm manage-db [command]
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Error during database management:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
18
scripts/run-migrations.ts
Normal file
18
scripts/run-migrations.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { addMirroredLocationColumn } from "../src/lib/db/migrations/add-mirrored-location";
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
try {
|
||||||
|
console.log("Running database migrations...");
|
||||||
|
|
||||||
|
// Run the migration to add the mirrored_location column
|
||||||
|
await addMirroredLocationColumn();
|
||||||
|
|
||||||
|
console.log("All migrations completed successfully");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Migration failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrations();
|
||||||
202
src/components/activity/ActivityList.tsx
Normal file
202
src/components/activity/ActivityList.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { useMemo, useRef, useState, useEffect } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import type { MirrorJob } from "@/lib/db/schema";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import type { FilterParams } from "@/types/filter";
|
||||||
|
|
||||||
|
interface ActivityListProps {
|
||||||
|
activities: MirrorJob[];
|
||||||
|
isLoading: boolean;
|
||||||
|
filter: FilterParams;
|
||||||
|
setFilter: (filter: FilterParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityList({
|
||||||
|
activities,
|
||||||
|
isLoading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
}: ActivityListProps) {
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
|
|
||||||
|
const filteredActivities = useMemo(() => {
|
||||||
|
let result = activities;
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
result = result.filter((activity) => activity.status === filter.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.type) {
|
||||||
|
if (filter.type === 'repository') {
|
||||||
|
result = result.filter((activity) => !!activity.repositoryId);
|
||||||
|
} else if (filter.type === 'organization') {
|
||||||
|
result = result.filter((activity) => !!activity.organizationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.name) {
|
||||||
|
result = result.filter((activity) =>
|
||||||
|
activity.repositoryName === filter.name ||
|
||||||
|
activity.organizationName === filter.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.searchTerm) {
|
||||||
|
const fuse = new Fuse(result, {
|
||||||
|
keys: ["message", "details", "organizationName", "repositoryName"],
|
||||||
|
threshold: 0.3,
|
||||||
|
});
|
||||||
|
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [activities, filter]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: filteredActivities.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: (index) => {
|
||||||
|
const activity = filteredActivities[index];
|
||||||
|
return expandedItems.has(activity.id || "") ? 217 : 120;
|
||||||
|
},
|
||||||
|
overscan: 5,
|
||||||
|
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
virtualizer.measure();
|
||||||
|
}, [expandedItems, virtualizer]);
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
{Array.from({ length: 5 }, (_, index) => (
|
||||||
|
<Skeleton key={index} className="h-28 w-full rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredActivities.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<RefreshCw className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium">No activities found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||||
|
{filter.searchTerm || filter.status || filter.type || filter.name
|
||||||
|
? "Try adjusting your search or filter criteria."
|
||||||
|
: "No mirroring activities have been recorded yet."}
|
||||||
|
</p>
|
||||||
|
{filter.searchTerm || filter.status || filter.type || filter.name ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter({ searchTerm: "", status: "", type: "", name: "" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
className="border rounded-md max-h-[calc(100dvh-191px)] overflow-y-auto relative"
|
||||||
|
ref={parentRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: virtualizer.getTotalSize(),
|
||||||
|
position: "relative",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const activity = filteredActivities[virtualRow.index];
|
||||||
|
const isExpanded = expandedItems.has(activity.id || "");
|
||||||
|
const key = activity.id || String(virtualRow.index);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
rowRefs.current.set(key, node);
|
||||||
|
virtualizer.measureElement(node);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
paddingBottom: "8px",
|
||||||
|
}}
|
||||||
|
className="border-b px-4 pt-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
|
activity.status
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1">
|
||||||
|
<p className="font-medium">{activity.message}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatDate(activity.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.repositoryName && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Repository: {activity.repositoryName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.organizationName && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Organization: {activity.organizationName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activity.details && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newSet = new Set(expandedItems);
|
||||||
|
const id = activity.id || "";
|
||||||
|
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
|
||||||
|
setExpandedItems(newSet);
|
||||||
|
}}
|
||||||
|
className="text-xs h-7 px-2"
|
||||||
|
>
|
||||||
|
{isExpanded ? "Hide Details" : "Show Details"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<pre className="mt-2 p-3 bg-muted rounded-md text-xs overflow-auto whitespace-pre-wrap min-h-[100px]">
|
||||||
|
{activity.details}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
313
src/components/activity/ActivityLog.tsx
Normal file
313
src/components/activity/ActivityLog.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Search, Download, RefreshCw, ChevronDown } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu";
|
||||||
|
import { apiRequest, formatDate } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import type { MirrorJob } from "@/lib/db/schema";
|
||||||
|
import type { ActivityApiResponse } from "@/types/activities";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { repoStatusEnum, type RepoStatus } from "@/types/Repository";
|
||||||
|
import ActivityList from "./ActivityList";
|
||||||
|
import { ActivityNameCombobox } from "./ActivityNameCombobox";
|
||||||
|
import { useSSE } from "@/hooks/useSEE";
|
||||||
|
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function ActivityLog() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const { filter, setFilter } = useFilterParams({
|
||||||
|
searchTerm: "",
|
||||||
|
status: "",
|
||||||
|
type: "",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||||
|
setActivities((prevActivities) => [data, ...prevActivities]);
|
||||||
|
|
||||||
|
console.log("Received new log:", data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use the SSE hook
|
||||||
|
const { connected } = useSSE({
|
||||||
|
userId: user?.id,
|
||||||
|
onMessage: handleNewMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchActivities = useCallback(async () => {
|
||||||
|
if (!user) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const response = await apiRequest<ActivityApiResponse>(
|
||||||
|
`/activities?userId=${user.id}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setActivities(response.activities);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(response.message || "Failed to fetch activities.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to fetch activities."
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActivities();
|
||||||
|
}, [fetchActivities]);
|
||||||
|
|
||||||
|
const handleRefreshActivities = async () => {
|
||||||
|
const success = await fetchActivities();
|
||||||
|
if (success) {
|
||||||
|
toast.success("Activities refreshed successfully.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the currently filtered activities
|
||||||
|
const getFilteredActivities = () => {
|
||||||
|
return activities.filter(activity => {
|
||||||
|
let isIncluded = true;
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
isIncluded = isIncluded && activity.status === filter.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.type) {
|
||||||
|
if (filter.type === 'repository') {
|
||||||
|
isIncluded = isIncluded && !!activity.repositoryId;
|
||||||
|
} else if (filter.type === 'organization') {
|
||||||
|
isIncluded = isIncluded && !!activity.organizationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.name) {
|
||||||
|
isIncluded = isIncluded && (
|
||||||
|
activity.repositoryName === filter.name ||
|
||||||
|
activity.organizationName === filter.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We're not applying the search term filter here as that would require
|
||||||
|
// re-implementing the Fuse.js search logic
|
||||||
|
|
||||||
|
return isIncluded;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to export activities as CSV
|
||||||
|
const exportAsCSV = () => {
|
||||||
|
const filteredActivities = getFilteredActivities();
|
||||||
|
|
||||||
|
if (filteredActivities.length === 0) {
|
||||||
|
toast.error("No activities to export.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CSV content
|
||||||
|
const headers = ["Timestamp", "Message", "Status", "Repository", "Organization", "Details"];
|
||||||
|
const csvRows = [
|
||||||
|
headers.join(","),
|
||||||
|
...filteredActivities.map(activity => {
|
||||||
|
const formattedDate = formatDate(activity.timestamp);
|
||||||
|
// Escape fields that might contain commas or quotes
|
||||||
|
const escapeCsvField = (field: string | null | undefined) => {
|
||||||
|
if (!field) return '';
|
||||||
|
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
|
||||||
|
return `"${field.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
formattedDate,
|
||||||
|
escapeCsvField(activity.message),
|
||||||
|
activity.status,
|
||||||
|
escapeCsvField(activity.repositoryName || ''),
|
||||||
|
escapeCsvField(activity.organizationName || ''),
|
||||||
|
escapeCsvField(activity.details || '')
|
||||||
|
].join(',');
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const csvContent = csvRows.join('\n');
|
||||||
|
|
||||||
|
// Download the CSV file
|
||||||
|
downloadFile(csvContent, 'text/csv;charset=utf-8;', 'activity_log_export.csv');
|
||||||
|
|
||||||
|
toast.success("Activity log exported as CSV successfully.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to export activities as JSON
|
||||||
|
const exportAsJSON = () => {
|
||||||
|
const filteredActivities = getFilteredActivities();
|
||||||
|
|
||||||
|
if (filteredActivities.length === 0) {
|
||||||
|
toast.error("No activities to export.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the activities for export (removing any sensitive or unnecessary fields if needed)
|
||||||
|
const activitiesForExport = filteredActivities.map(activity => ({
|
||||||
|
id: activity.id,
|
||||||
|
timestamp: activity.timestamp,
|
||||||
|
formattedTime: formatDate(activity.timestamp),
|
||||||
|
message: activity.message,
|
||||||
|
status: activity.status,
|
||||||
|
repositoryId: activity.repositoryId,
|
||||||
|
repositoryName: activity.repositoryName,
|
||||||
|
organizationId: activity.organizationId,
|
||||||
|
organizationName: activity.organizationName,
|
||||||
|
details: activity.details
|
||||||
|
}));
|
||||||
|
|
||||||
|
const jsonContent = JSON.stringify(activitiesForExport, null, 2);
|
||||||
|
|
||||||
|
// Download the JSON file
|
||||||
|
downloadFile(jsonContent, 'application/json', 'activity_log_export.json');
|
||||||
|
|
||||||
|
toast.success("Activity log exported as JSON successfully.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generic function to download a file
|
||||||
|
const downloadFile = (content: string, mimeType: string, filename: string) => {
|
||||||
|
// Add date to filename
|
||||||
|
const date = new Date();
|
||||||
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
const filenameWithDate = filename.replace('.', `_${dateStr}.`);
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', filenameWithDate);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-8">
|
||||||
|
<div className="flex flex-row items-center gap-4 w-full">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search activities..."
|
||||||
|
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={filter.searchTerm}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={filter.status || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: value === "all" ? "" : (value as RepoStatus),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||||
|
<SelectValue placeholder="All Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["all", ...repoStatusEnum.options].map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status === "all"
|
||||||
|
? "All Status"
|
||||||
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Repository/Organization Name Combobox */}
|
||||||
|
<ActivityNameCombobox
|
||||||
|
activities={activities}
|
||||||
|
value={filter.name || ""}
|
||||||
|
onChange={(name: string) => setFilter((prev) => ({ ...prev, name }))}
|
||||||
|
/>
|
||||||
|
{/* Filter by type: repository/org/all */}
|
||||||
|
<Select
|
||||||
|
value={filter.type || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: value === "all" ? "" : value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||||
|
<SelectValue placeholder="All Types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{['all', 'repository', 'organization'].map((type) => (
|
||||||
|
<SelectItem key={type} value={type}>
|
||||||
|
{type === 'all' ? 'All Types' : type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="flex items-center gap-1">
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
Export
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={exportAsCSV}>
|
||||||
|
Export as CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={exportAsJSON}>
|
||||||
|
Export as JSON
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button onClick={handleRefreshActivities}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-6">
|
||||||
|
<ActivityList
|
||||||
|
activities={activities}
|
||||||
|
isLoading={isLoading || !connected}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/activity/ActivityNameCombobox.tsx
Normal file
86
src/components/activity/ActivityNameCombobox.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronsUpDown, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ActivityNameComboboxProps = {
|
||||||
|
activities: any[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityNameCombobox({ activities, value, onChange }: ActivityNameComboboxProps) {
|
||||||
|
// Collect unique names from repositoryName and organizationName
|
||||||
|
const names = React.useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
activities.forEach((a) => {
|
||||||
|
if (a.repositoryName) set.add(a.repositoryName);
|
||||||
|
if (a.organizationName) set.add(a.organizationName);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [activities]);
|
||||||
|
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[180px] justify-between"
|
||||||
|
>
|
||||||
|
{value ? value : "All Names"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[180px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search name..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No name found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
key="all"
|
||||||
|
value=""
|
||||||
|
onSelect={() => {
|
||||||
|
onChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||||
|
All Names
|
||||||
|
</CommandItem>
|
||||||
|
{names.map((name) => (
|
||||||
|
<CommandItem
|
||||||
|
key={name}
|
||||||
|
value={name}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(name);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === name ? "opacity-100" : "opacity-0")} />
|
||||||
|
{name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/auth/LoginForm.tsx
Normal file
117
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { SiGitea } from 'react-icons/si';
|
||||||
|
import { toast, Toaster } from 'sonner';
|
||||||
|
import { FlipHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const username = formData.get('username') as string | null;
|
||||||
|
const password = formData.get('password') as string | null;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
toast.error('Please enter both username and password');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = { username, password };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(loginData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Login successful!');
|
||||||
|
// Small delay before redirecting to see the success message
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || 'Login failed. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('An error occurred while logging in. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<SiGitea className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Gitea Mirror</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Log in to manage your GitHub to Gitea mirroring
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form id="login-form" onSubmit={handleLogin}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" form="login-form" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Logging in...' : 'Log In'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
<div className="px-6 pb-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Don't have an account? Contact your administrator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/auth/SignupForm.tsx
Normal file
146
src/components/auth/SignupForm.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { GitMerge } from 'lucide-react';
|
||||||
|
import { toast, Toaster } from 'sonner';
|
||||||
|
|
||||||
|
export function SignupForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSignup(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const username = formData.get('username') as string | null;
|
||||||
|
const email = formData.get('email') as string | null;
|
||||||
|
const password = formData.get('password') as string | null;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string | null;
|
||||||
|
|
||||||
|
if (!username || !email || !password || !confirmPassword) {
|
||||||
|
toast.error('Please fill in all fields');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error('Passwords do not match');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signupData = { username, email, password };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(signupData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Account created successfully! Redirecting to dashboard...');
|
||||||
|
// Small delay before redirecting to see the success message
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || 'Failed to create account. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('An error occurred while creating your account. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<GitMerge className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Create Admin Account</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Set up your administrator account for Gitea Mirror
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form id="signup-form" onSubmit={handleSignup}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Create a password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button type="submit" form="signup-form" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating Account...' : 'Create Admin Account'}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
398
src/components/config/ConfigTabs.tsx
Normal file
398
src/components/config/ConfigTabs.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { GitHubConfigForm } from "./GitHubConfigForm";
|
||||||
|
import { GiteaConfigForm } from "./GiteaConfigForm";
|
||||||
|
import { ScheduleConfigForm } from "./ScheduleConfigForm";
|
||||||
|
import type {
|
||||||
|
ConfigApiResponse,
|
||||||
|
GiteaConfig,
|
||||||
|
GitHubConfig,
|
||||||
|
SaveConfigApiRequest,
|
||||||
|
SaveConfigApiResponse,
|
||||||
|
ScheduleConfig,
|
||||||
|
} from "@/types/config";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { apiRequest } from "@/lib/utils";
|
||||||
|
import { Copy, CopyCheck, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type ConfigState = {
|
||||||
|
githubConfig: GitHubConfig;
|
||||||
|
giteaConfig: GiteaConfig;
|
||||||
|
scheduleConfig: ScheduleConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfigTabs() {
|
||||||
|
const [config, setConfig] = useState<ConfigState>({
|
||||||
|
githubConfig: {
|
||||||
|
username: "",
|
||||||
|
token: "",
|
||||||
|
skipForks: false,
|
||||||
|
privateRepositories: false,
|
||||||
|
mirrorIssues: false,
|
||||||
|
mirrorStarred: false,
|
||||||
|
preserveOrgStructure: false,
|
||||||
|
skipStarredIssues: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
giteaConfig: {
|
||||||
|
url: "",
|
||||||
|
username: "",
|
||||||
|
token: "",
|
||||||
|
organization: "github-mirrors",
|
||||||
|
visibility: "public",
|
||||||
|
starredReposOrg: "github",
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleConfig: {
|
||||||
|
enabled: false,
|
||||||
|
interval: 3600,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { user, refreshUser } = useAuth();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [dockerCode, setDockerCode] = useState<string>("");
|
||||||
|
const [isCopied, setIsCopied] = useState<boolean>(false);
|
||||||
|
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||||
|
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Check if all required fields are filled to enable the Save Configuration button
|
||||||
|
const isConfigFormValid = (): boolean => {
|
||||||
|
const { githubConfig, giteaConfig } = config;
|
||||||
|
|
||||||
|
// Check GitHub required fields
|
||||||
|
const isGitHubValid = !!(
|
||||||
|
githubConfig.username?.trim() && githubConfig.token?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check Gitea required fields
|
||||||
|
const isGiteaValid = !!(
|
||||||
|
giteaConfig.url?.trim() &&
|
||||||
|
giteaConfig.username?.trim() &&
|
||||||
|
giteaConfig.token?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return isGitHubValid && isGiteaValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateLastAndNextRun = () => {
|
||||||
|
const lastRun = config.scheduleConfig.lastRun
|
||||||
|
? new Date(config.scheduleConfig.lastRun)
|
||||||
|
: new Date(); // fallback to now if lastRun is null
|
||||||
|
const intervalInSeconds = config.scheduleConfig.interval;
|
||||||
|
const nextRun = new Date(lastRun.getTime() + intervalInSeconds * 1000);
|
||||||
|
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
scheduleConfig: {
|
||||||
|
...prev.scheduleConfig,
|
||||||
|
lastRun,
|
||||||
|
nextRun,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateLastAndNextRun();
|
||||||
|
}, [config.scheduleConfig.interval]);
|
||||||
|
|
||||||
|
const handleImportGitHubData = async () => {
|
||||||
|
try {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
|
||||||
|
const result = await apiRequest<{ success: boolean; message?: string }>(
|
||||||
|
`/sync?userId=${user.id}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
"GitHub data imported successfully! Head to the Dashboard to start mirroring repositories."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
`Failed to import GitHub data: ${result.message || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Error importing GitHub data: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveConfig = async () => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqPyload: SaveConfigApiRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
githubConfig: config.githubConfig,
|
||||||
|
giteaConfig: config.giteaConfig,
|
||||||
|
scheduleConfig: config.scheduleConfig,
|
||||||
|
};
|
||||||
|
const response = await fetch("/api/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(reqPyload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: SaveConfigApiResponse = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await refreshUser();
|
||||||
|
setIsConfigSaved(true);
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
"Configuration saved successfully! Now import your GitHub data to begin."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
`Failed to save configuration: ${result.message || "Unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`An error occurred while saving the configuration: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const response = await apiRequest<ConfigApiResponse>(
|
||||||
|
`/config?userId=${user.id}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we have a valid config response
|
||||||
|
if (response && !response.error) {
|
||||||
|
setConfig({
|
||||||
|
githubConfig: response.githubConfig || config.githubConfig,
|
||||||
|
giteaConfig: response.giteaConfig || config.giteaConfig,
|
||||||
|
scheduleConfig: response.scheduleConfig || config.scheduleConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we got a valid config from the server, it means it was previously saved
|
||||||
|
if (response.id) {
|
||||||
|
setIsConfigSaved(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If there's an error, we'll just use the default config defined in state
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
// Don't show error for first-time users, just use the default config
|
||||||
|
console.warn("Could not fetch configuration, using defaults:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchConfig();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const generateDockerCode = () => {
|
||||||
|
return `version: "3.3"
|
||||||
|
services:
|
||||||
|
gitea-mirror:
|
||||||
|
image: arunavo4/gitea-mirror:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
container_name: gitea-mirror
|
||||||
|
environment:
|
||||||
|
- GITHUB_USERNAME=${config.githubConfig.username}
|
||||||
|
- GITEA_URL=${config.giteaConfig.url}
|
||||||
|
- GITEA_TOKEN=${config.giteaConfig.token}
|
||||||
|
- GITHUB_TOKEN=${config.githubConfig.token}
|
||||||
|
- SKIP_FORKS=${config.githubConfig.skipForks}
|
||||||
|
- PRIVATE_REPOSITORIES=${config.githubConfig.privateRepositories}
|
||||||
|
- MIRROR_ISSUES=${config.githubConfig.mirrorIssues}
|
||||||
|
- MIRROR_STARRED=${config.githubConfig.mirrorStarred}
|
||||||
|
- PRESERVE_ORG_STRUCTURE=${config.githubConfig.preserveOrgStructure}
|
||||||
|
- SKIP_STARRED_ISSUES=${config.githubConfig.skipStarredIssues}
|
||||||
|
- GITEA_ORGANIZATION=${config.giteaConfig.organization}
|
||||||
|
- GITEA_ORG_VISIBILITY=${config.giteaConfig.visibility}
|
||||||
|
- DELAY=${config.scheduleConfig.interval}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const code = generateDockerCode();
|
||||||
|
setDockerCode(code);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const handleCopyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => {
|
||||||
|
setIsCopied(true);
|
||||||
|
toast.success("Docker configuration copied to clipboard!");
|
||||||
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
toast.error("Could not copy text to clipboard.");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<div>loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row justify-between">
|
||||||
|
<div className="flex flex-col gap-y-1.5 m-0">
|
||||||
|
<CardTitle>Configuration Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure your GitHub and Gitea connections, and set up automatic
|
||||||
|
mirroring.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-x-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleImportGitHubData}
|
||||||
|
disabled={isSyncing || !isConfigSaved}
|
||||||
|
title={
|
||||||
|
!isConfigSaved
|
||||||
|
? "Save configuration first"
|
||||||
|
: isSyncing
|
||||||
|
? "Import in progress"
|
||||||
|
: "Import GitHub Data"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
Import GitHub Data
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Import GitHub Data
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveConfig}
|
||||||
|
disabled={!isConfigFormValid()}
|
||||||
|
title={
|
||||||
|
!isConfigFormValid()
|
||||||
|
? "Please fill all required fields"
|
||||||
|
: "Save Configuration"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div className="flex gap-x-4">
|
||||||
|
<GitHubConfigForm
|
||||||
|
config={config.githubConfig}
|
||||||
|
setConfig={(update) =>
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
githubConfig:
|
||||||
|
typeof update === "function"
|
||||||
|
? update(prev.githubConfig)
|
||||||
|
: update,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GiteaConfigForm
|
||||||
|
config={config?.giteaConfig ?? ({} as GiteaConfig)}
|
||||||
|
setConfig={(update) =>
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
giteaConfig:
|
||||||
|
typeof update === "function"
|
||||||
|
? update(prev.giteaConfig)
|
||||||
|
: update,
|
||||||
|
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
|
||||||
|
scheduleConfig:
|
||||||
|
prev?.scheduleConfig ?? ({} as ScheduleConfig),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScheduleConfigForm
|
||||||
|
config={config?.scheduleConfig ?? ({} as ScheduleConfig)}
|
||||||
|
setConfig={(update) =>
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
scheduleConfig:
|
||||||
|
typeof update === "function"
|
||||||
|
? update(prev.scheduleConfig)
|
||||||
|
: update,
|
||||||
|
githubConfig: prev?.githubConfig ?? ({} as GitHubConfig),
|
||||||
|
giteaConfig: prev?.giteaConfig ?? ({} as GiteaConfig),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Docker Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Equivalent Docker configuration for your current settings.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="relative">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-4 right-10"
|
||||||
|
onClick={() => handleCopyToClipboard(dockerCode)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<CopyCheck className="text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<pre className="bg-muted p-4 rounded-md overflow-auto text-sm">
|
||||||
|
{dockerCode}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
src/components/config/GitHubConfigForm.tsx
Normal file
340
src/components/config/GitHubConfigForm.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { githubApi } from "@/lib/api";
|
||||||
|
import type { GitHubConfig } from "@/types/config";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { Alert, AlertDescription } from "../ui/alert";
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||||
|
|
||||||
|
interface GitHubConfigFormProps {
|
||||||
|
config: GitHubConfig;
|
||||||
|
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
|
||||||
|
// Special handling for preserveOrgStructure changes
|
||||||
|
if (
|
||||||
|
name === "preserveOrgStructure" &&
|
||||||
|
config.preserveOrgStructure !== checked
|
||||||
|
) {
|
||||||
|
toast.info(
|
||||||
|
"Changing this setting may affect how repositories are accessed in Gitea. " +
|
||||||
|
"Existing mirrored repositories will still be accessible during sync operations.",
|
||||||
|
{
|
||||||
|
duration: 6000,
|
||||||
|
position: "top-center",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
[name]: type === "checkbox" ? checked : value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
if (!config.token) {
|
||||||
|
toast.error("GitHub token is required to test the connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await githubApi.testConnection(config.token);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Successfully connected to GitHub!");
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to connect to GitHub. Please check your token.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||||
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
GitHub Configuration
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={isLoading || !config.token}
|
||||||
|
>
|
||||||
|
{isLoading ? "Testing..." : "Test Connection"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="github-username"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
GitHub Username
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="github-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
value={config.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Your GitHub username"
|
||||||
|
required
|
||||||
|
className="bg-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="github-token"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
GitHub Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="github-token"
|
||||||
|
name="token"
|
||||||
|
type="password"
|
||||||
|
value={config.token}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="bg-background"
|
||||||
|
placeholder="Your GitHub personal access token"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Required for private repositories, organizations, and starred
|
||||||
|
repositories.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="skip-forks"
|
||||||
|
name="skipForks"
|
||||||
|
checked={config.skipForks}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "skipForks",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="skip-forks"
|
||||||
|
className="ml-2 block text-sm select-none"
|
||||||
|
>
|
||||||
|
Skip Forks
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="private-repositories"
|
||||||
|
name="privateRepositories"
|
||||||
|
checked={config.privateRepositories}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "privateRepositories",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="private-repositories"
|
||||||
|
className="ml-2 block text-sm select-none"
|
||||||
|
>
|
||||||
|
Mirror Private Repos
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="mirror-starred"
|
||||||
|
name="mirrorStarred"
|
||||||
|
checked={config.mirrorStarred}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "mirrorStarred",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="mirror-starred"
|
||||||
|
className="ml-2 block text-sm select-none"
|
||||||
|
>
|
||||||
|
Mirror Starred Repos
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="mirror-issues"
|
||||||
|
name="mirrorIssues"
|
||||||
|
checked={config.mirrorIssues}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "mirrorIssues",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="mirror-issues"
|
||||||
|
className="ml-2 block text-sm select-none"
|
||||||
|
>
|
||||||
|
Mirror Issues
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="preserve-org-structure"
|
||||||
|
name="preserveOrgStructure"
|
||||||
|
checked={config.preserveOrgStructure}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "preserveOrgStructure",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="preserve-org-structure"
|
||||||
|
className="ml-2 text-sm select-none flex items-center"
|
||||||
|
>
|
||||||
|
Preserve Org Structure
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span
|
||||||
|
className="ml-1 cursor-pointer align-middle text-muted-foreground"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Info size={16} />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs text-xs">
|
||||||
|
When enabled, organization repositories will be mirrored to
|
||||||
|
the same organization structure in Gitea. When disabled, all
|
||||||
|
repositories will be mirrored under your Gitea username.
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="skip-starred-issues"
|
||||||
|
name="skipStarredIssues"
|
||||||
|
checked={config.skipStarredIssues}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "skipStarredIssues",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="skip-starred-issues"
|
||||||
|
className="ml-2 block text-sm select-none"
|
||||||
|
>
|
||||||
|
Skip Issues for Starred Repos
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="flex-col items-start">
|
||||||
|
<Alert variant="note" className="w-full">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" />
|
||||||
|
<AlertDescription className="text-sm">
|
||||||
|
<div className="font-semibold mb-1">Note:</div>
|
||||||
|
<div className="mb-1">
|
||||||
|
You need to create a{" "}
|
||||||
|
<span className="font-semibold">Classic GitHub PAT Token</span>{" "}
|
||||||
|
with following scopes:
|
||||||
|
</div>
|
||||||
|
<ul className="ml-4 mb-1 list-disc">
|
||||||
|
<li>
|
||||||
|
<code>repo</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>admin:org</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mb-1">
|
||||||
|
The organization access is required for mirroring organization
|
||||||
|
repositories.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
You can generate tokens at{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/settings/tokens"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline font-medium hover:text-blue-900 dark:hover:text-blue-200"
|
||||||
|
>
|
||||||
|
github.com/settings/tokens
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/components/config/GiteaConfigForm.tsx
Normal file
228
src/components/config/GiteaConfigForm.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { giteaApi } from "@/lib/api";
|
||||||
|
import type { GiteaConfig, GiteaOrgVisibility } from "@/types/config";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface GiteaConfigFormProps {
|
||||||
|
config: GiteaConfig;
|
||||||
|
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
if (!config.url || !config.token) {
|
||||||
|
toast.error("Gitea URL and token are required to test the connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await giteaApi.testConnection(config.url, config.token);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Successfully connected to Gitea!");
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
"Failed to connect to Gitea. Please check your URL and token."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||||
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
Gitea Configuration
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={isLoading || !config.url || !config.token}
|
||||||
|
>
|
||||||
|
{isLoading ? "Testing..." : "Test Connection"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-y-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="gitea-username"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Gitea Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gitea-username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
value={config.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Your Gitea username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="gitea-url"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Gitea URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gitea-url"
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
value={config.url}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="https://your-gitea-instance.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="gitea-token"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Gitea Token
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gitea-token"
|
||||||
|
name="token"
|
||||||
|
type="password"
|
||||||
|
value={config.token}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Your Gitea access token"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Create a token in your Gitea instance under Settings >
|
||||||
|
Applications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="organization"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Default Organization (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="organization"
|
||||||
|
name="organization"
|
||||||
|
type="text"
|
||||||
|
value={config.organization}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="Organization name"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
If specified, repositories will be mirrored to this organization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="visibility"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Organization Visibility
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
name="visibility"
|
||||||
|
value={config.visibility}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleChange({
|
||||||
|
target: { name: "visibility", value },
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||||
|
<SelectValue placeholder="Select visibility" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||||
|
{(["public", "private", "limited"] as GiteaOrgVisibility[]).map(
|
||||||
|
(option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="starred-repos-org"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Starred Repositories Organization
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="starred-repos-org"
|
||||||
|
name="starredReposOrg"
|
||||||
|
type="text"
|
||||||
|
value={config.starredReposOrg}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="github"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Organization for starred repositories (default: github)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="">
|
||||||
|
{/* Footer content can be added here if needed */}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/components/config/ScheduleConfigForm.tsx
Normal file
139
src/components/config/ScheduleConfigForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "../ui/checkbox";
|
||||||
|
import type { ScheduleConfig } from "@/types/config";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
|
||||||
|
interface ScheduleConfigFormProps {
|
||||||
|
config: ScheduleConfig;
|
||||||
|
setConfig: React.Dispatch<React.SetStateAction<ScheduleConfig>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleConfigForm({
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
|
}: ScheduleConfigFormProps) {
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
[name]:
|
||||||
|
type === "checkbox" ? (e.target as HTMLInputElement).checked : value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert seconds to human-readable format
|
||||||
|
const formatInterval = (seconds: number): string => {
|
||||||
|
if (seconds < 60) return `${seconds} seconds`;
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`;
|
||||||
|
return `${Math.floor(seconds / 86400)} days`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Predefined intervals
|
||||||
|
const intervals: { value: number; label: string }[] = [
|
||||||
|
// { value: 120, label: "2 minutes" }, //for testing
|
||||||
|
{ value: 900, label: "15 minutes" },
|
||||||
|
{ value: 1800, label: "30 minutes" },
|
||||||
|
{ value: 3600, label: "1 hour" },
|
||||||
|
{ value: 7200, label: "2 hours" },
|
||||||
|
{ value: 14400, label: "4 hours" },
|
||||||
|
{ value: 28800, label: "8 hours" },
|
||||||
|
{ value: 43200, label: "12 hours" },
|
||||||
|
{ value: 86400, label: "1 day" },
|
||||||
|
{ value: 172800, label: "2 days" },
|
||||||
|
{ value: 604800, label: "1 week" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
checked={config.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange({
|
||||||
|
target: {
|
||||||
|
name: "enabled",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: Boolean(checked),
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enabled"
|
||||||
|
className="select-none ml-2 block text-sm font-medium"
|
||||||
|
>
|
||||||
|
Enable Automatic Mirroring
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="interval"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Mirroring Interval
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
name="interval"
|
||||||
|
value={String(config.interval)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleChange({
|
||||||
|
target: { name: "interval", value },
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||||
|
<SelectValue placeholder="Select interval" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||||
|
{intervals.map((interval) => (
|
||||||
|
<SelectItem
|
||||||
|
key={interval.value}
|
||||||
|
value={interval.value.toString()}
|
||||||
|
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{interval.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
How often the mirroring process should run.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.lastRun && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Last Run</label>
|
||||||
|
<div className="text-sm">{formatDate(config.lastRun)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.nextRun && config.enabled && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Next Run</label>
|
||||||
|
<div className="text-sm">{formatDate(config.nextRun)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/dashboard/Dashboard.tsx
Normal file
145
src/components/dashboard/Dashboard.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { StatusCard } from "./StatusCard";
|
||||||
|
import { RecentActivity } from "./RecentActivity";
|
||||||
|
import { RepositoryList } from "./RepositoryList";
|
||||||
|
import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { apiRequest } from "@/lib/utils";
|
||||||
|
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||||
|
import { useSSE } from "@/hooks/useSEE";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [activities, setActivities] = useState<MirrorJob[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [repoCount, setRepoCount] = useState<number>(0);
|
||||||
|
const [orgCount, setOrgCount] = useState<number>(0);
|
||||||
|
const [mirroredCount, setMirroredCount] = useState<number>(0);
|
||||||
|
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
// Create a stable callback using useCallback
|
||||||
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||||
|
if (data.repositoryId) {
|
||||||
|
setRepositories((prevRepos) =>
|
||||||
|
prevRepos.map((repo) =>
|
||||||
|
repo.id === data.repositoryId
|
||||||
|
? { ...repo, status: data.status, details: data.details }
|
||||||
|
: repo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (data.organizationId) {
|
||||||
|
setOrganizations((prevOrgs) =>
|
||||||
|
prevOrgs.map((org) =>
|
||||||
|
org.id === data.organizationId
|
||||||
|
? { ...org, status: data.status, details: data.details }
|
||||||
|
: org
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivities((prevActivities) => [data, ...prevActivities]);
|
||||||
|
|
||||||
|
console.log("Received new log:", data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use the SSE hook
|
||||||
|
const { connected } = useSSE({
|
||||||
|
userId: user?.id,
|
||||||
|
onMessage: handleNewMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
const response = await apiRequest<DashboardApiResponse>(
|
||||||
|
`/dashboard?userId=${user.id}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setRepositories(response.repositories);
|
||||||
|
setOrganizations(response.organizations);
|
||||||
|
setActivities(response.activities);
|
||||||
|
setRepoCount(response.repoCount);
|
||||||
|
setOrgCount(response.orgCount);
|
||||||
|
setMirroredCount(response.mirroredCount);
|
||||||
|
setLastSync(response.lastSync);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error fetching dashboard data");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Error fetching dashboard data"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDashboardData();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
return isLoading || !connected ? (
|
||||||
|
<div>loading...</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatusCard
|
||||||
|
title="Total Repositories"
|
||||||
|
value={repoCount}
|
||||||
|
icon={<GitFork className="h-4 w-4" />}
|
||||||
|
description="Repositories being mirrored"
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
title="Mirrored"
|
||||||
|
value={mirroredCount}
|
||||||
|
icon={<FlipHorizontal className="h-4 w-4" />}
|
||||||
|
description="Successfully mirrored"
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
title="Organizations"
|
||||||
|
value={orgCount}
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
description="GitHub organizations"
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
title="Last Sync"
|
||||||
|
value={
|
||||||
|
lastSync
|
||||||
|
? new Date(lastSync).toLocaleString("en-US", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
year: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: "N/A"
|
||||||
|
}
|
||||||
|
icon={<Clock className="h-4 w-4" />}
|
||||||
|
description="Last successful sync"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-x-6 items-start">
|
||||||
|
<RepositoryList repositories={repositories} />
|
||||||
|
|
||||||
|
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
||||||
|
<RecentActivity activities={activities.slice(0, 10)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/dashboard/RecentActivity.tsx
Normal file
48
src/components/dashboard/RecentActivity.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { MirrorJob } from "@/lib/db/schema";
|
||||||
|
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
|
||||||
|
interface RecentActivityProps {
|
||||||
|
activities: MirrorJob[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/activity">View All</a>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||||
|
<div className="flex flex-col divide-y divide-border">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No recent activity</p>
|
||||||
|
) : (
|
||||||
|
activities.map((activity, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-x-4 py-4">
|
||||||
|
<div className="relative mt-1">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
|
activity.status
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">
|
||||||
|
{activity.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(activity.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/dashboard/RepositoryList.tsx
Normal file
92
src/components/dashboard/RepositoryList.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GitFork } from "lucide-react";
|
||||||
|
import { SiGithub } from "react-icons/si";
|
||||||
|
import type { Repository } from "@/lib/db/schema";
|
||||||
|
import { getStatusColor } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface RepositoryListProps {
|
||||||
|
repositories: Repository[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryList({ repositories }: RepositoryListProps) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
{/* calculating the max height based non the other elements and sizing styles */}
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Repositories</CardTitle>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<a href="/repositories">View All</a>
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||||
|
{repositories.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
|
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium">No repositories found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 mb-4">
|
||||||
|
Configure your GitHub connection to start mirroring repositories.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/config">Configure GitHub</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col divide-y divide-border">
|
||||||
|
{repositories.map((repo, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between gap-x-4 py-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-sm font-medium">{repo.name}</h4>
|
||||||
|
{repo.isPrivate && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{repo.owner}
|
||||||
|
</span>
|
||||||
|
{repo.organization && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
• {repo.organization}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
|
repo.status
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs capitalize w-[3rem]">
|
||||||
|
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
||||||
|
{repo.status}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<GitFork className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={repo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<SiGithub className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/dashboard/StatusCard.tsx
Normal file
33
src/components/dashboard/StatusCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface StatusCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: StatusCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={cn("overflow-hidden", className)}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||||
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||||
|
<div className="h-4 w-4 text-muted-foreground">{icon}</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/layout/Header.tsx
Normal file
49
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { SiGitea } from "react-icons/si";
|
||||||
|
import { ModeToggle } from "@/components/theme/ModeToggle";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
toast.success("Logged out successfully");
|
||||||
|
// Small delay to show the toast before redirecting
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b bg-background">
|
||||||
|
<div className="flex h-[4.5rem] items-center justify-between px-6">
|
||||||
|
<a href="/" className="flex items-center gap-2 py-1">
|
||||||
|
<SiGitea className="h-6 w-6" />
|
||||||
|
<span className="text-xl font-bold">Gitea Mirror</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ModeToggle />
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="" alt="@shadcn" />
|
||||||
|
<AvatarFallback>
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Button variant="outline" size="lg" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="lg" asChild>
|
||||||
|
<a href="/login">Login</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/layout/MainLayout.tsx
Normal file
61
src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Header } from "./Header";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { Dashboard } from "@/components/dashboard/Dashboard";
|
||||||
|
import Repository from "../repositories/Repository";
|
||||||
|
import Providers from "./Providers";
|
||||||
|
import { ConfigTabs } from "../config/ConfigTabs";
|
||||||
|
import { ActivityLog } from "../activity/ActivityLog";
|
||||||
|
import { Organization } from "../organizations/Organization";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useRepoSync } from "@/hooks/useSyncRepo";
|
||||||
|
|
||||||
|
interface AppProps {
|
||||||
|
page:
|
||||||
|
| "dashboard"
|
||||||
|
| "repositories"
|
||||||
|
| "organizations"
|
||||||
|
| "configuration"
|
||||||
|
| "activity-log";
|
||||||
|
"client:load"?: boolean;
|
||||||
|
"client:idle"?: boolean;
|
||||||
|
"client:visible"?: boolean;
|
||||||
|
"client:media"?: string;
|
||||||
|
"client:only"?: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App({ page }: AppProps) {
|
||||||
|
return (
|
||||||
|
<Providers>
|
||||||
|
<AppWithProviders page={page} />
|
||||||
|
</Providers>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppWithProviders({ page }: AppProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
useRepoSync({
|
||||||
|
userId: user?.id,
|
||||||
|
enabled: user?.syncEnabled,
|
||||||
|
interval: user?.syncInterval,
|
||||||
|
lastSync: user?.lastSync,
|
||||||
|
nextSync: user?.nextSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<Sidebar />
|
||||||
|
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
|
||||||
|
{page === "dashboard" && <Dashboard />}
|
||||||
|
{page === "repositories" && <Repository />}
|
||||||
|
{page === "organizations" && <Organization />}
|
||||||
|
{page === "configuration" && <ConfigTabs />}
|
||||||
|
{page === "activity-log" && <ActivityLog />}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/layout/Providers.tsx
Normal file
13
src/components/layout/Providers.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { AuthProvider } from "@/hooks/useAuth";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
{children}
|
||||||
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/layout/Sidebar.tsx
Normal file
66
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { links } from "@/data/Sidebar";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ className }: SidebarProps) {
|
||||||
|
const [currentPath, setCurrentPath] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Hydration happens here
|
||||||
|
const path = window.location.pathname;
|
||||||
|
setCurrentPath(path);
|
||||||
|
console.log("Hydrated path:", path); // Should log now
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className={cn("w-64 border-r bg-background", className)}>
|
||||||
|
<div className="flex flex-col h-full py-4">
|
||||||
|
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
|
||||||
|
{links.map((link, index) => {
|
||||||
|
const isActive = currentPath === link.href;
|
||||||
|
const Icon = link.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={link.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-auto px-4 py-4">
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
Check out the documentation for help with setup and configuration.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="/docs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
src/components/organizations/AddOrganizationDialog.tsx
Normal file
150
src/components/organizations/AddOrganizationDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { LoaderCircle, Plus } from "lucide-react";
|
||||||
|
import type { MembershipRole } from "@/types/organizations";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "../ui/radio";
|
||||||
|
import { Label } from "../ui/label";
|
||||||
|
|
||||||
|
interface AddOrganizationDialogProps {
|
||||||
|
isDialogOpen: boolean;
|
||||||
|
setIsDialogOpen: (isOpen: boolean) => void;
|
||||||
|
onAddOrganization: ({
|
||||||
|
org,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
org: string;
|
||||||
|
role: MembershipRole;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddOrganizationDialog({
|
||||||
|
isDialogOpen,
|
||||||
|
setIsDialogOpen,
|
||||||
|
onAddOrganization,
|
||||||
|
}: AddOrganizationDialogProps) {
|
||||||
|
const [org, setOrg] = useState<string>("");
|
||||||
|
const [role, setRole] = useState<MembershipRole>("member");
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!org || org.trim() === "") {
|
||||||
|
setError("Please enter a valid organization name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
await onAddOrganization({ org, role });
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
setOrg("");
|
||||||
|
setRole("member");
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || "Failed to add repository.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Organization</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You can add public organizations
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Organization Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={org}
|
||||||
|
onChange={(e) => setOrg(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="e.g., microsoft"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Membership Role
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={role}
|
||||||
|
onValueChange={(val) => setRole(val as MembershipRole)}
|
||||||
|
className="flex flex-col gap-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="member" id="r1" />
|
||||||
|
<Label htmlFor="r1">Member</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="admin" id="r2" />
|
||||||
|
<Label htmlFor="r2">Admin</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="billing_manager" id="r3" />
|
||||||
|
<Label htmlFor="r3">Billing Manager</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => setIsDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Add Repository"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
src/components/organizations/Organization.tsx
Normal file
377
src/components/organizations/Organization.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Search, RefreshCw, FlipHorizontal, Plus } from "lucide-react";
|
||||||
|
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||||
|
import { OrganizationList } from "./OrganizationsList";
|
||||||
|
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { apiRequest } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
membershipRoleEnum,
|
||||||
|
type AddOrganizationApiRequest,
|
||||||
|
type AddOrganizationApiResponse,
|
||||||
|
type MembershipRole,
|
||||||
|
type OrganizationsApiResponse,
|
||||||
|
} from "@/types/organizations";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
|
||||||
|
import { useSSE } from "@/hooks/useSEE";
|
||||||
|
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function Organization() {
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { filter, setFilter } = useFilterParams({
|
||||||
|
searchTerm: "",
|
||||||
|
membershipRole: "",
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
|
const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
|
||||||
|
|
||||||
|
// Create a stable callback using useCallback
|
||||||
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||||
|
if (data.organizationId) {
|
||||||
|
setOrganizations((prevOrgs) =>
|
||||||
|
prevOrgs.map((org) =>
|
||||||
|
org.id === data.organizationId
|
||||||
|
? { ...org, status: data.status, details: data.details }
|
||||||
|
: org
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Received new log:", data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use the SSE hook
|
||||||
|
const { connected } = useSSE({
|
||||||
|
userId: user?.id,
|
||||||
|
onMessage: handleNewMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchOrganizations = useCallback(async () => {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const response = await apiRequest<OrganizationsApiResponse>(
|
||||||
|
`/github/organizations?userId=${user.id}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setOrganizations(response.organizations);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error fetching organizations");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error fetching organizations"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrganizations();
|
||||||
|
}, [fetchOrganizations]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
const success = await fetchOrganizations();
|
||||||
|
if (success) {
|
||||||
|
toast.success("Organizations refreshed successfully.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMirrorOrg = async ({ orgId }: { orgId: string }) => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingOrgIds((prev) => new Set(prev).add(orgId));
|
||||||
|
|
||||||
|
const reqPayload: MirrorOrgRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
organizationIds: [orgId],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Mirroring started for organization ID: ${orgId}`);
|
||||||
|
|
||||||
|
setOrganizations((prevOrgs) =>
|
||||||
|
prevOrgs.map((org) => {
|
||||||
|
const updated = response.organizations.find((o) => o.id === org.id);
|
||||||
|
return updated ? updated : org;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error starting mirror job");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting mirror job"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingOrgIds((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(orgId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOrganization = async ({
|
||||||
|
org,
|
||||||
|
role,
|
||||||
|
}: {
|
||||||
|
org: string;
|
||||||
|
role: MembershipRole;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqPayload: AddOrganizationApiRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
org,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<AddOrganizationApiResponse>(
|
||||||
|
"/sync/organization",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Organization added successfully`);
|
||||||
|
setOrganizations((prev) => [...prev, response.organization]);
|
||||||
|
|
||||||
|
await fetchOrganizations();
|
||||||
|
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
searchTerm: org,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error adding organization");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error adding organization"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMirrorAllOrgs = async () => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id || organizations.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out organizations that are already mirrored to avoid duplicate operations
|
||||||
|
const eligibleOrgs = organizations.filter(
|
||||||
|
(org) =>
|
||||||
|
org.status !== "mirroring" && org.status !== "mirrored" && org.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eligibleOrgs.length === 0) {
|
||||||
|
toast.info("No eligible organizations to mirror");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all organization IDs
|
||||||
|
const orgIds = eligibleOrgs.map((org) => org.id as string);
|
||||||
|
|
||||||
|
// Set loading state for all organizations being mirrored
|
||||||
|
setLoadingOrgIds((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
orgIds.forEach((id) => newSet.add(id));
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqPayload: MirrorOrgRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
organizationIds: orgIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<MirrorOrgResponse>("/job/mirror-org", {
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Mirroring started for ${orgIds.length} organizations`);
|
||||||
|
setOrganizations((prevOrgs) =>
|
||||||
|
prevOrgs.map((org) => {
|
||||||
|
const updated = response.organizations.find((o) => o.id === org.id);
|
||||||
|
return updated ? updated : org;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error starting mirror jobs");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting mirror jobs"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Reset loading states - we'll let the SSE updates handle status changes
|
||||||
|
setLoadingOrgIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique organization names for combobox (since Organization has no owner field)
|
||||||
|
const ownerOptions = Array.from(
|
||||||
|
new Set(
|
||||||
|
organizations.map((org) => org.name).filter((v): v is string => !!v)
|
||||||
|
)
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-8">
|
||||||
|
{/* Combine search and actions into a single flex row */}
|
||||||
|
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search Organizations..."
|
||||||
|
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={filter.searchTerm}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Membership Role Filter */}
|
||||||
|
<Select
|
||||||
|
value={filter.membershipRole || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
membershipRole: value === "all" ? "" : (value as MembershipRole),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||||
|
<SelectValue placeholder="All Roles" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["all", ...membershipRoleEnum.options].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{role === "all"
|
||||||
|
? "All Roles"
|
||||||
|
: role
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<Select
|
||||||
|
value={filter.status || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status:
|
||||||
|
value === "all"
|
||||||
|
? ""
|
||||||
|
: (value as
|
||||||
|
| ""
|
||||||
|
| "imported"
|
||||||
|
| "mirroring"
|
||||||
|
| "mirrored"
|
||||||
|
| "failed"
|
||||||
|
| "syncing"
|
||||||
|
| "synced"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||||
|
<SelectValue placeholder="All Statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[
|
||||||
|
"all",
|
||||||
|
"imported",
|
||||||
|
"mirroring",
|
||||||
|
"mirrored",
|
||||||
|
"failed",
|
||||||
|
"syncing",
|
||||||
|
"synced",
|
||||||
|
].map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status === "all"
|
||||||
|
? "All Statuses"
|
||||||
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button variant="default" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleMirrorAllOrgs}
|
||||||
|
disabled={isLoading || loadingOrgIds.size > 0}
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Mirror All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrganizationList
|
||||||
|
organizations={organizations}
|
||||||
|
isLoading={isLoading || !connected}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
loadingOrgIds={loadingOrgIds}
|
||||||
|
onMirror={handleMirrorOrg}
|
||||||
|
onAddOrganization={() => setIsDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddOrganizationDialog
|
||||||
|
onAddOrganization={handleAddOrganization}
|
||||||
|
isDialogOpen={isDialogOpen}
|
||||||
|
setIsDialogOpen={setIsDialogOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/organizations/OrganizationsList.tsx
Normal file
178
src/components/organizations/OrganizationsList.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, RefreshCw, Building2 } from "lucide-react";
|
||||||
|
import { SiGithub } from "react-icons/si";
|
||||||
|
import type { Organization } from "@/lib/db/schema";
|
||||||
|
import type { FilterParams } from "@/types/filter";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { getStatusColor } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OrganizationListProps {
|
||||||
|
organizations: Organization[];
|
||||||
|
isLoading: boolean;
|
||||||
|
filter: FilterParams;
|
||||||
|
setFilter: (filter: FilterParams) => void;
|
||||||
|
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||||
|
loadingOrgIds: Set<string>;
|
||||||
|
onAddOrganization?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationList({
|
||||||
|
organizations,
|
||||||
|
isLoading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
onMirror,
|
||||||
|
loadingOrgIds,
|
||||||
|
onAddOrganization,
|
||||||
|
}: OrganizationListProps) {
|
||||||
|
const hasAnyFilter = Object.values(filter).some(
|
||||||
|
(val) => val?.toString().trim() !== ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredOrganizations = useMemo(() => {
|
||||||
|
let result = organizations;
|
||||||
|
|
||||||
|
if (filter.membershipRole) {
|
||||||
|
result = result.filter((org) => org.membershipRole === filter.membershipRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
result = result.filter((org) => org.status === filter.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.searchTerm) {
|
||||||
|
const fuse = new Fuse(result, {
|
||||||
|
keys: ["name", "type"],
|
||||||
|
threshold: 0.3,
|
||||||
|
});
|
||||||
|
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [organizations, filter]);
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[136px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredOrganizations.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium">No organizations found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||||
|
{hasAnyFilter
|
||||||
|
? "Try adjusting your search or filter criteria."
|
||||||
|
: "Add GitHub organizations to mirror their repositories."}
|
||||||
|
</p>
|
||||||
|
{hasAnyFilter ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter({
|
||||||
|
searchTerm: "",
|
||||||
|
membershipRole: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onAddOrganization}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Organization
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredOrganizations.map((org, index) => {
|
||||||
|
const isLoading = loadingOrgIds.has(org.id ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={index} className="overflow-hidden p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<a
|
||||||
|
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||||
|
className="font-medium hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded-full capitalize ${
|
||||||
|
org.membershipRole === "member"
|
||||||
|
? "bg-blue-100 text-blue-800"
|
||||||
|
: "bg-purple-100 text-purple-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{org.membershipRole}
|
||||||
|
{/* needs to be updated */}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{org.repositoryCount}{" "}
|
||||||
|
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Checkbox
|
||||||
|
id={`include-${org.id}`}
|
||||||
|
name={`include-${org.id}`}
|
||||||
|
checked={org.status === "mirrored"}
|
||||||
|
disabled={
|
||||||
|
loadingOrgIds.has(org.id ?? "") ||
|
||||||
|
org.status === "mirrored" ||
|
||||||
|
org.status === "mirroring"
|
||||||
|
}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
if (checked && !org.isIncluded && org.id) {
|
||||||
|
onMirror({ orgId: org.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`include-${org.id}`}
|
||||||
|
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Include in mirroring
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${org.name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<SiGithub className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* dont know if this looks good. maybe revised */}
|
||||||
|
<div className="flex items-center gap-2 justify-end mt-4">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm capitalize">{org.status}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/repositories/AddRepositoryDialog.tsx
Normal file
141
src/components/repositories/AddRepositoryDialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../ui/dialog";
|
||||||
|
import { LoaderCircle, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface AddRepositoryDialogProps {
|
||||||
|
isDialogOpen: boolean;
|
||||||
|
setIsDialogOpen: (isOpen: boolean) => void;
|
||||||
|
onAddRepository: ({
|
||||||
|
repo,
|
||||||
|
owner,
|
||||||
|
}: {
|
||||||
|
repo: string;
|
||||||
|
owner: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddRepositoryDialog({
|
||||||
|
isDialogOpen,
|
||||||
|
setIsDialogOpen,
|
||||||
|
onAddRepository,
|
||||||
|
}: AddRepositoryDialogProps) {
|
||||||
|
const [repo, setRepo] = useState<string>("");
|
||||||
|
const [owner, setOwner] = useState<string>("");
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!repo || !owner || repo.trim() === "" || owner.trim() === "") {
|
||||||
|
setError("Please enter a valid repository name and owner.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
await onAddRepository({ repo, owner });
|
||||||
|
|
||||||
|
setError("");
|
||||||
|
setRepo("");
|
||||||
|
setOwner("");
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || "Failed to add repository.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
|
||||||
|
<Plus className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Repository</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
You can add public repositories of others
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Repository Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={repo}
|
||||||
|
onChange={(e) => setRepo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="e.g., next.js"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="block text-sm font-medium mb-1.5"
|
||||||
|
>
|
||||||
|
Repository Owner
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={owner}
|
||||||
|
onChange={(e) => setOwner(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
placeholder="e.g., vercel"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => setIsDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Add Repository"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
457
src/components/repositories/Repository.tsx
Normal file
457
src/components/repositories/Repository.tsx
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import RepositoryTable from "./RepositoryTable";
|
||||||
|
import type { MirrorJob, Repository } from "@/lib/db/schema";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
repoStatusEnum,
|
||||||
|
type AddRepositoriesApiRequest,
|
||||||
|
type AddRepositoriesApiResponse,
|
||||||
|
type RepositoryApiResponse,
|
||||||
|
type RepoStatus,
|
||||||
|
} from "@/types/Repository";
|
||||||
|
import { apiRequest } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
|
||||||
|
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||||
|
import { useSSE } from "@/hooks/useSEE";
|
||||||
|
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync";
|
||||||
|
import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes";
|
||||||
|
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
|
||||||
|
import AddRepositoryDialog from "./AddRepositoryDialog";
|
||||||
|
|
||||||
|
export default function Repository() {
|
||||||
|
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { filter, setFilter } = useFilterParams({
|
||||||
|
searchTerm: "",
|
||||||
|
status: "",
|
||||||
|
organization: "",
|
||||||
|
owner: "",
|
||||||
|
});
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Read organization filter from URL when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const orgParam = urlParams.get("organization");
|
||||||
|
|
||||||
|
if (orgParam) {
|
||||||
|
setFilter((prev) => ({ ...prev, organization: orgParam }));
|
||||||
|
}
|
||||||
|
}, [setFilter]);
|
||||||
|
|
||||||
|
const [loadingRepoIds, setLoadingRepoIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
|
||||||
|
|
||||||
|
// Create a stable callback using useCallback
|
||||||
|
const handleNewMessage = useCallback((data: MirrorJob) => {
|
||||||
|
if (data.repositoryId) {
|
||||||
|
setRepositories((prevRepos) =>
|
||||||
|
prevRepos.map((repo) =>
|
||||||
|
repo.id === data.repositoryId
|
||||||
|
? { ...repo, status: data.status, details: data.details }
|
||||||
|
: repo
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Received new log:", data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Use the SSE hook
|
||||||
|
const { connected } = useSSE({
|
||||||
|
userId: user?.id,
|
||||||
|
onMessage: handleNewMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchRepositories = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiRequest<RepositoryApiResponse>(
|
||||||
|
`/github/repositories?userId=${user.id}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setRepositories(response.repositories);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error fetching repositories");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error fetching repositories"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRepositories();
|
||||||
|
}, [fetchRepositories]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
const success = await fetchRepositories();
|
||||||
|
if (success) {
|
||||||
|
toast.success("Repositories refreshed successfully.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMirrorRepo = async ({ repoId }: { repoId: string }) => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
|
||||||
|
|
||||||
|
const reqPayload: MirrorRepoRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
repositoryIds: [repoId],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<MirrorRepoResponse>(
|
||||||
|
"/job/mirror-repo",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Mirroring started for repository ID: ${repoId}`);
|
||||||
|
setRepositories((prevRepos) =>
|
||||||
|
prevRepos.map((repo) => {
|
||||||
|
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||||
|
return updated ? updated : repo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error starting mirror job");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting mirror job"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingRepoIds((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(repoId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMirrorAllRepos = async () => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id || repositories.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out repositories that are already mirroring to avoid duplicate operations. also filter out mirrored (mirrored can be synced and not mirrored again)
|
||||||
|
const eligibleRepos = repositories.filter(
|
||||||
|
(repo) =>
|
||||||
|
repo.status !== "mirroring" && repo.status !== "mirrored" && repo.id //not ignoring failed ones because we want to retry them if not mirrored. if mirrored, gitea fucnion handlers will silently ignore them
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eligibleRepos.length === 0) {
|
||||||
|
toast.info("No eligible repositories to mirror");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all repository IDs
|
||||||
|
const repoIds = eligibleRepos.map((repo) => repo.id as string);
|
||||||
|
|
||||||
|
// Set loading state for all repositories being mirrored
|
||||||
|
setLoadingRepoIds((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
repoIds.forEach((id) => newSet.add(id));
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqPayload: MirrorRepoRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
repositoryIds: repoIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<MirrorRepoResponse>(
|
||||||
|
"/job/mirror-repo",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Mirroring started for ${repoIds.length} repositories`);
|
||||||
|
setRepositories((prevRepos) =>
|
||||||
|
prevRepos.map((repo) => {
|
||||||
|
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||||
|
return updated ? updated : repo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error starting mirror jobs");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting mirror jobs"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Reset loading states - we'll let the SSE updates handle status changes
|
||||||
|
setLoadingRepoIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
|
||||||
|
|
||||||
|
const reqPayload: SyncRepoRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
repositoryIds: [repoId],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Syncing started for repository ID: ${repoId}`);
|
||||||
|
setRepositories((prevRepos) =>
|
||||||
|
prevRepos.map((repo) => {
|
||||||
|
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||||
|
return updated ? updated : repo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error starting sync job");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error starting sync job"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingRepoIds((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(repoId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetryRepoAction = async ({ repoId }: { repoId: string }) => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingRepoIds((prev) => new Set(prev).add(repoId));
|
||||||
|
|
||||||
|
const reqPayload: RetryRepoRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
repositoryIds: [repoId],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<RetryRepoResponse>("/job/retry-repo", {
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Retrying job for repository ID: ${repoId}`);
|
||||||
|
setRepositories((prevRepos) =>
|
||||||
|
prevRepos.map((repo) => {
|
||||||
|
const updated = response.repositories.find((r) => r.id === repo.id);
|
||||||
|
return updated ? updated : repo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error retrying job");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error retrying job"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingRepoIds((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(repoId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepository = async ({
|
||||||
|
repo,
|
||||||
|
owner,
|
||||||
|
}: {
|
||||||
|
repo: string;
|
||||||
|
owner: string;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqPayload: AddRepositoriesApiRequest = {
|
||||||
|
userId: user.id,
|
||||||
|
repo,
|
||||||
|
owner,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiRequest<AddRepositoriesApiResponse>(
|
||||||
|
"/sync/repository",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
data: reqPayload,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Repository added successfully`);
|
||||||
|
setRepositories((prevRepos) => [...prevRepos, response.repository]);
|
||||||
|
|
||||||
|
await fetchRepositories();
|
||||||
|
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
searchTerm: repo,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
toast.error(response.error || "Error adding repository");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Error adding repository"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique owners and organizations for comboboxes
|
||||||
|
const ownerOptions = Array.from(
|
||||||
|
new Set(
|
||||||
|
repositories.map((repo) => repo.owner).filter((v): v is string => !!v)
|
||||||
|
)
|
||||||
|
).sort();
|
||||||
|
const orgOptions = Array.from(
|
||||||
|
new Set(
|
||||||
|
repositories
|
||||||
|
.map((repo) => repo.organization)
|
||||||
|
.filter((v): v is string => !!v)
|
||||||
|
)
|
||||||
|
).sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-8">
|
||||||
|
{/* Combine search and actions into a single flex row */}
|
||||||
|
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
|
||||||
|
<div className="relative flex-grow min-w-[180px]">
|
||||||
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search repositories..."
|
||||||
|
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={filter.searchTerm}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner Combobox */}
|
||||||
|
<OwnerCombobox
|
||||||
|
options={ownerOptions}
|
||||||
|
value={filter.owner || ""}
|
||||||
|
onChange={(owner: string) =>
|
||||||
|
setFilter((prev) => ({ ...prev, owner }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Organization Combobox */}
|
||||||
|
<OrganizationCombobox
|
||||||
|
options={orgOptions}
|
||||||
|
value={filter.organization || ""}
|
||||||
|
onChange={(organization: string) =>
|
||||||
|
setFilter((prev) => ({ ...prev, organization }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filter.status || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: value === "all" ? "" : (value as RepoStatus),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
||||||
|
<SelectValue placeholder="All Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["all", ...repoStatusEnum.options].map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status === "all"
|
||||||
|
? "All Status"
|
||||||
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button variant="default" onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleMirrorAllRepos}
|
||||||
|
disabled={isLoading || loadingRepoIds.size > 0}
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Mirror All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RepositoryTable
|
||||||
|
repositories={repositories}
|
||||||
|
isLoading={isLoading || !connected}
|
||||||
|
filter={filter}
|
||||||
|
setFilter={setFilter}
|
||||||
|
onMirror={handleMirrorRepo}
|
||||||
|
onSync={handleSyncRepo}
|
||||||
|
onRetry={handleRetryRepoAction}
|
||||||
|
loadingRepoIds={loadingRepoIds}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddRepositoryDialog
|
||||||
|
onAddRepository={handleAddRepository}
|
||||||
|
isDialogOpen={isDialogOpen}
|
||||||
|
setIsDialogOpen={setIsDialogOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/repositories/RepositoryComboboxes.tsx
Normal file
131
src/components/repositories/RepositoryComboboxes.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronsUpDown, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ComboboxProps = {
|
||||||
|
options: string[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OwnerCombobox({ options, value, onChange, placeholder = "Owner" }: ComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[160px] justify-between"
|
||||||
|
>
|
||||||
|
{value ? value : placeholder}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[160px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
key="all"
|
||||||
|
value=""
|
||||||
|
onSelect={() => {
|
||||||
|
onChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||||
|
All
|
||||||
|
</CommandItem>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(option);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
|
||||||
|
{option}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationCombobox({ options, value, onChange, placeholder = "Organization" }: ComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[160px] justify-between"
|
||||||
|
>
|
||||||
|
{value ? value : placeholder}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[160px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
key="all"
|
||||||
|
value=""
|
||||||
|
onSelect={() => {
|
||||||
|
onChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||||
|
All
|
||||||
|
</CommandItem>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option}
|
||||||
|
value={option}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(option);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
|
||||||
|
{option}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
src/components/repositories/RepositoryTable.tsx
Normal file
363
src/components/repositories/RepositoryTable.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { GitFork, RefreshCw, RotateCcw } from "lucide-react";
|
||||||
|
import { SiGithub } from "react-icons/si";
|
||||||
|
import type { Repository } from "@/lib/db/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||||
|
import type { FilterParams } from "@/types/filter";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
interface RepositoryTableProps {
|
||||||
|
repositories: Repository[];
|
||||||
|
isLoading: boolean;
|
||||||
|
filter: FilterParams;
|
||||||
|
setFilter: (filter: FilterParams) => void;
|
||||||
|
onMirror: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||||
|
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||||
|
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||||
|
loadingRepoIds: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RepositoryTable({
|
||||||
|
repositories,
|
||||||
|
isLoading,
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
onMirror,
|
||||||
|
onSync,
|
||||||
|
onRetry,
|
||||||
|
loadingRepoIds,
|
||||||
|
}: RepositoryTableProps) {
|
||||||
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const hasAnyFilter = Object.values(filter).some(
|
||||||
|
(val) => val?.toString().trim() !== ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredRepositories = useMemo(() => {
|
||||||
|
let result = repositories;
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
result = result.filter((repo) => repo.status === filter.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.owner) {
|
||||||
|
result = result.filter((repo) => repo.owner === filter.owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.organization) {
|
||||||
|
result = result.filter(
|
||||||
|
(repo) => repo.organization === filter.organization
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.searchTerm) {
|
||||||
|
const fuse = new Fuse(result, {
|
||||||
|
keys: ["name", "fullName", "owner", "organization"],
|
||||||
|
threshold: 0.3,
|
||||||
|
});
|
||||||
|
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [repositories, filter]);
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: filteredRepositories.length,
|
||||||
|
getScrollElement: () => tableParentRef.current,
|
||||||
|
estimateSize: () => 65,
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
return isLoading ? (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||||
|
Repository
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
Organization
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
Last Mirrored
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
||||||
|
>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
|
||||||
|
<Skeleton className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredRepositories.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium">No repositories found</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||||
|
{hasAnyFilter
|
||||||
|
? "Try adjusting your search or filter criteria."
|
||||||
|
: "Configure your GitHub connection to start mirroring repositories."}
|
||||||
|
</p>
|
||||||
|
{hasAnyFilter ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
setFilter({
|
||||||
|
searchTerm: "",
|
||||||
|
status: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<a href="/config">Configure GitHub</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col border rounded-md">
|
||||||
|
{/* table header */}
|
||||||
|
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||||
|
Repository
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
Organization
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
Last Mirrored
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1] text-right">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* table body wrapper (for a parent in virtualization) */}
|
||||||
|
<div
|
||||||
|
ref={tableParentRef}
|
||||||
|
className="flex flex-col max-h-[calc(100dvh-236px)] overflow-y-auto" //the height is set according to the other contents
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
||||||
|
const repo = filteredRepositories[virtualRow.index];
|
||||||
|
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
|
||||||
|
>
|
||||||
|
{/* Repository */}
|
||||||
|
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
||||||
|
<GitFork className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{repo.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{repo.fullName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{repo.isPrivate && (
|
||||||
|
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
|
Private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner */}
|
||||||
|
<div className="h-full p-3 flex items-center flex-[1]">
|
||||||
|
<p className="text-sm">{repo.owner}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization */}
|
||||||
|
<div className="h-full p-3 flex items-center flex-[1]">
|
||||||
|
<p className="text-sm"> {repo.organization || "-"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Mirrored */}
|
||||||
|
<div className="h-full p-3 flex items-center flex-[1]">
|
||||||
|
<p className="text-sm">
|
||||||
|
{repo.lastMirrored
|
||||||
|
? formatDate(new Date(repo.lastMirrored))
|
||||||
|
: "Never"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
|
repo.status
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm capitalize">{repo.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="h-full p-3 flex items-center justify-end gap-x-2 flex-[1]">
|
||||||
|
{/* {repo.status === "mirrored" ||
|
||||||
|
repo.status === "syncing" ||
|
||||||
|
repo.status === "synced" ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={repo.status === "syncing" || isLoading}
|
||||||
|
onClick={() => onSync({ repoId: repo.id ?? "" })}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
Sync
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Sync
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
disabled={repo.status === "mirroring" || isLoading}
|
||||||
|
onClick={() => onMirror({ repoId: repo.id ?? "" })}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
Mirror
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Mirror
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
<RepoActionButton
|
||||||
|
repo={{ id: repo.id ?? "", status: repo.status }}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onMirror={({ repoId }) =>
|
||||||
|
onMirror({ repoId: repo.id ?? "" })
|
||||||
|
}
|
||||||
|
onSync={({ repoId }) => onSync({ repoId: repo.id ?? "" })}
|
||||||
|
onRetry={({ repoId }) => onRetry({ repoId: repo.id ?? "" })}
|
||||||
|
/>
|
||||||
|
<Button variant="ghost" size="icon" asChild>
|
||||||
|
<a
|
||||||
|
href={repo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<SiGithub className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RepoActionButton({
|
||||||
|
repo,
|
||||||
|
isLoading,
|
||||||
|
onMirror,
|
||||||
|
onSync,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
repo: { id: string; status: string };
|
||||||
|
isLoading: boolean;
|
||||||
|
onMirror: ({ repoId }: { repoId: string }) => void;
|
||||||
|
onSync: ({ repoId }: { repoId: string }) => void;
|
||||||
|
onRetry: ({ repoId }: { repoId: string }) => void;
|
||||||
|
}) {
|
||||||
|
const repoId = repo.id ?? "";
|
||||||
|
|
||||||
|
let label = "";
|
||||||
|
let icon = <></>;
|
||||||
|
let onClick = () => {};
|
||||||
|
let disabled = isLoading;
|
||||||
|
|
||||||
|
if (repo.status === "failed") {
|
||||||
|
label = "Retry";
|
||||||
|
icon = <RotateCcw className="h-4 w-4 mr-1" />;
|
||||||
|
onClick = () => onRetry({ repoId });
|
||||||
|
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
|
||||||
|
label = "Sync";
|
||||||
|
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
||||||
|
onClick = () => onSync({ repoId });
|
||||||
|
disabled ||= repo.status === "syncing";
|
||||||
|
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||||
|
label = "Mirror";
|
||||||
|
icon = <RefreshCw className="h-4 w-4 mr-1" />;
|
||||||
|
onClick = () => onMirror({ repoId });
|
||||||
|
disabled ||= repo.status === "mirroring";
|
||||||
|
} else {
|
||||||
|
return null; // unsupported status
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" disabled={disabled} onClick={onClick}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/components/theme/ModeToggle.tsx
Normal file
52
src/components/theme/ModeToggle.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export function ModeToggle() {
|
||||||
|
const [theme, setThemeState] = React.useState<"light" | "dark" | "system">(
|
||||||
|
"light"
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const isDarkMode = document.documentElement.classList.contains("dark");
|
||||||
|
setThemeState(isDarkMode ? "dark" : "light");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const isDark =
|
||||||
|
theme === "dark" ||
|
||||||
|
(theme === "system" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList[isDark ? "add" : "remove"]("dark");
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="lg" className="has-[>svg]:px-3">
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => setThemeState("light")}>
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setThemeState("dark")}>
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setThemeState("system")}>
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/theme/ThemeScript.astro
Normal file
21
src/components/theme/ThemeScript.astro
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
const getThemePreference = () => {
|
||||||
|
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||||
|
return localStorage.getItem('theme');
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
const isDark = getThemePreference() === 'dark';
|
||||||
|
document.documentElement.classList[isDark ? 'add' : 'remove']('dark');
|
||||||
|
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
70
src/components/ui/alert.tsx
Normal file
70
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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",
|
||||||
|
warning:
|
||||||
|
"bg-amber-50 border-amber-200 text-amber-800 dark:bg-amber-950/30 dark:border-amber-800 dark:text-amber-300 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-500",
|
||||||
|
note:
|
||||||
|
"bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-950/30 dark:border-blue-800 dark:text-blue-200 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 }
|
||||||
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal file
@@ -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<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
28
src/components/ui/checkbox.tsx
Normal file
28
src/components/ui/checkbox.tsx
Normal file
@@ -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<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("flex items-center justify-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
175
src/components/ui/command.tsx
Normal file
175
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_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=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
135
src/components/ui/dialog.tsx
Normal file
135
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
255
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 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
|
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,
|
||||||
|
}
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
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<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
44
src/components/ui/radio.tsx
Normal file
44
src/components/ui/radio.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import { Circle } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const RadioGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
const RadioGroupItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||||
|
<Circle className="h-3.5 w-3.5 fill-primary" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
183
src/components/ui/select.tsx
Normal file
183
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input cursor-pointer data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
24
src/components/ui/sonner.tsx
Normal file
24
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
import type { ToasterProps } from "sonner"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
55
src/components/ui/tooltip.tsx
Normal file
55
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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 <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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-primary text-primary-foreground 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-[var(--radix-tooltip-content-transform-origin)] rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
17
src/content/config.ts
Normal file
17
src/content/config.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineCollection, z } from 'astro:content';
|
||||||
|
|
||||||
|
// Define a schema for the documentation collection
|
||||||
|
const docsCollection = defineCollection({
|
||||||
|
type: 'content',
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
order: z.number().optional(),
|
||||||
|
updatedDate: z.date().optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export the collections
|
||||||
|
export const collections = {
|
||||||
|
'docs': docsCollection,
|
||||||
|
};
|
||||||
103
src/content/docs/architecture.md
Normal file
103
src/content/docs/architecture.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
title: "Architecture"
|
||||||
|
description: "Comprehensive overview of the Gitea Mirror application architecture."
|
||||||
|
order: 1
|
||||||
|
updatedDate: 2023-10-15
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Architecture</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">This document provides a comprehensive overview of the Gitea Mirror application architecture, including component diagrams, project structure, and detailed explanations of each part of the system.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-muted-foreground">Gitea Mirror is a web application that automates the mirroring of GitHub repositories to Gitea instances. It provides a user-friendly interface for configuring, monitoring, and managing mirroring operations without requiring users to edit configuration files or run Docker commands.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
The application is built using:
|
||||||
|
|
||||||
|
- <span class="font-semibold text-foreground">Astro</span>: Web framework for the frontend
|
||||||
|
- <span class="font-semibold text-foreground">React</span>: Component library for interactive UI elements
|
||||||
|
- <span class="font-semibold text-foreground">Shadcn UI</span>: UI component library built on Tailwind CSS
|
||||||
|
- <span class="font-semibold text-foreground">SQLite</span>: Database for storing configuration and state
|
||||||
|
- <span class="font-semibold text-foreground">Node.js</span>: Runtime environment for the backend
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph "Gitea Mirror"
|
||||||
|
Frontend["Frontend<br/>(Astro)"]
|
||||||
|
Backend["Backend<br/>(Node.js)"]
|
||||||
|
Database["Database<br/>(SQLite)"]
|
||||||
|
|
||||||
|
Frontend <--> Backend
|
||||||
|
Backend <--> Database
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "External APIs"
|
||||||
|
GitHub["GitHub API"]
|
||||||
|
Gitea["Gitea API"]
|
||||||
|
end
|
||||||
|
|
||||||
|
Backend --> GitHub
|
||||||
|
Backend --> Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Breakdown
|
||||||
|
|
||||||
|
### Frontend (Astro + React)
|
||||||
|
|
||||||
|
The frontend is built with Astro, a modern web framework that allows for server-side rendering and partial hydration. React components are used for interactive elements, providing a responsive and dynamic user interface.
|
||||||
|
|
||||||
|
Key frontend components:
|
||||||
|
|
||||||
|
- **Dashboard**: Overview of mirroring status and recent activity
|
||||||
|
- **Repository Management**: Interface for managing repositories to mirror
|
||||||
|
- **Organization Management**: Interface for managing GitHub organizations
|
||||||
|
- **Configuration**: Settings for GitHub and Gitea connections
|
||||||
|
- **Activity Log**: Detailed log of mirroring operations
|
||||||
|
|
||||||
|
### Backend (Node.js)
|
||||||
|
|
||||||
|
The backend is built with Node.js and provides API endpoints for the frontend to interact with. It handles:
|
||||||
|
|
||||||
|
- Authentication and user management
|
||||||
|
- GitHub API integration
|
||||||
|
- Gitea API integration
|
||||||
|
- Mirroring operations
|
||||||
|
- Database interactions
|
||||||
|
|
||||||
|
### Database (SQLite)
|
||||||
|
|
||||||
|
SQLite is used for data persistence, storing:
|
||||||
|
|
||||||
|
- User accounts and authentication data
|
||||||
|
- GitHub and Gitea configuration
|
||||||
|
- Repository and organization information
|
||||||
|
- Mirroring job history and status
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. **User Authentication**: Users authenticate through the frontend, which communicates with the backend to validate credentials.
|
||||||
|
2. **Configuration**: Users configure GitHub and Gitea settings through the UI, which are stored in the SQLite database.
|
||||||
|
3. **Repository Discovery**: The backend queries the GitHub API to discover repositories based on user configuration.
|
||||||
|
4. **Mirroring Process**: When triggered, the backend fetches repository data from GitHub and pushes it to Gitea.
|
||||||
|
5. **Status Tracking**: All operations are logged in the database and displayed in the Activity Log.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea-mirror/
|
||||||
|
├── src/ # Source code
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── layouts/ # Astro layout components
|
||||||
|
│ ├── lib/ # Utility functions and database
|
||||||
|
│ ├── pages/ # Astro pages and API routes
|
||||||
|
│ └── styles/ # CSS and Tailwind styles
|
||||||
|
├── public/ # Static assets
|
||||||
|
├── data/ # Database and persistent data
|
||||||
|
└── docker/ # Docker configuration
|
||||||
|
```
|
||||||
120
src/content/docs/configuration.md
Normal file
120
src/content/docs/configuration.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
---
|
||||||
|
title: "Configuration"
|
||||||
|
description: "Guide to configuring Gitea Mirror for your environment."
|
||||||
|
order: 2
|
||||||
|
updatedDate: 2023-10-15
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Configuration Guide</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">This guide provides detailed information on how to configure Gitea Mirror for your environment.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Configuration Methods
|
||||||
|
|
||||||
|
Gitea Mirror can be configured using:
|
||||||
|
|
||||||
|
1. <span class="font-semibold text-foreground">Environment Variables</span>: Set configuration options through environment variables
|
||||||
|
2. <span class="font-semibold text-foreground">Web UI</span>: Configure the application through the web interface after installation
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The following environment variables can be used to configure Gitea Mirror:
|
||||||
|
|
||||||
|
| Variable | Description | Default Value | Example |
|
||||||
|
|----------|-------------|---------------|---------|
|
||||||
|
| `NODE_ENV` | Node environment (development, production, test) | `development` | `production` |
|
||||||
|
| `DATABASE_URL` | SQLite database URL | `sqlite://data/gitea-mirror.db` | `sqlite://path/to/your/database.db` |
|
||||||
|
| `JWT_SECRET` | Secret key for JWT authentication | `your-secret-key-change-this-in-production` | `your-secure-random-string` |
|
||||||
|
| `HOST` | Server host | `localhost` | `0.0.0.0` |
|
||||||
|
| `PORT` | Server port | `3000` | `8080` |
|
||||||
|
|
||||||
|
### Important Security Note
|
||||||
|
|
||||||
|
In production environments, you should always set a strong, unique `JWT_SECRET` to ensure secure authentication.
|
||||||
|
|
||||||
|
## Web UI Configuration
|
||||||
|
|
||||||
|
After installing and starting Gitea Mirror, you can configure it through the web interface:
|
||||||
|
|
||||||
|
1. Navigate to `http://your-server:port/`
|
||||||
|
2. If this is your first time, you'll be guided through creating an admin account
|
||||||
|
3. Log in with your credentials
|
||||||
|
4. Go to the Configuration page
|
||||||
|
|
||||||
|
### GitHub Configuration
|
||||||
|
|
||||||
|
The GitHub configuration section allows you to connect to GitHub and specify which repositories to mirror.
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| Username | Your GitHub username | - |
|
||||||
|
| Token | GitHub personal access token | - |
|
||||||
|
| Skip Forks | Skip forked repositories | `false` |
|
||||||
|
| Private Repositories | Include private repositories | `false` |
|
||||||
|
| Mirror Issues | Mirror issues from GitHub to Gitea | `false` |
|
||||||
|
| Mirror Starred | Mirror starred repositories | `false` |
|
||||||
|
| Mirror Organizations | Mirror organization repositories | `false` |
|
||||||
|
| Only Mirror Orgs | Only mirror organization repositories | `false` |
|
||||||
|
| Preserve Org Structure | Maintain organization structure in Gitea | `false` |
|
||||||
|
| Skip Starred Issues | Skip mirroring issues for starred repositories | `false` |
|
||||||
|
|
||||||
|
#### GitHub Token Permissions
|
||||||
|
|
||||||
|
Your GitHub token needs the following permissions:
|
||||||
|
|
||||||
|
- `repo` - Full control of private repositories
|
||||||
|
- `read:org` - Read organization membership
|
||||||
|
- `read:user` - Read user profile data
|
||||||
|
|
||||||
|
To create a GitHub token:
|
||||||
|
|
||||||
|
1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
|
||||||
|
2. Click "Generate new token"
|
||||||
|
3. Select the required permissions
|
||||||
|
4. Copy the generated token and paste it into Gitea Mirror
|
||||||
|
|
||||||
|
### Gitea Configuration
|
||||||
|
|
||||||
|
The Gitea configuration section allows you to connect to your Gitea instance and specify how repositories should be mirrored.
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| URL | Gitea server URL | - |
|
||||||
|
| Token | Gitea access token | - |
|
||||||
|
| Organization | Default organization for mirrored repositories | - |
|
||||||
|
| Visibility | Default visibility for mirrored repositories | `public` |
|
||||||
|
| Starred Repos Org | Organization for starred repositories | `github` |
|
||||||
|
|
||||||
|
#### Gitea Token Creation
|
||||||
|
|
||||||
|
To create a Gitea access token:
|
||||||
|
|
||||||
|
1. Log in to your Gitea instance
|
||||||
|
2. Go to Settings > Applications
|
||||||
|
3. Under "Generate New Token", enter a name for your token
|
||||||
|
4. Click "Generate Token"
|
||||||
|
5. Copy the generated token and paste it into Gitea Mirror
|
||||||
|
|
||||||
|
### Schedule Configuration
|
||||||
|
|
||||||
|
You can configure automatic mirroring on a schedule:
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| Enable Scheduling | Enable automatic mirroring | `false` |
|
||||||
|
| Interval (seconds) | Time between mirroring operations | `3600` (1 hour) |
|
||||||
|
|
||||||
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Repository Filtering
|
||||||
|
|
||||||
|
You can include or exclude specific repositories using patterns:
|
||||||
|
|
||||||
|
- Include patterns: Only repositories matching these patterns will be mirrored
|
||||||
|
- Exclude patterns: Repositories matching these patterns will be skipped
|
||||||
|
|
||||||
|
Example patterns:
|
||||||
|
- `*` - All repositories
|
||||||
|
- `org-name/*` - All repositories in a specific organization
|
||||||
|
- `username/repo-name` - A specific repository
|
||||||
127
src/content/docs/quickstart.md
Normal file
127
src/content/docs/quickstart.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
title: "Quick Start Guide"
|
||||||
|
description: "Get started with Gitea Mirror quickly."
|
||||||
|
order: 3
|
||||||
|
updatedDate: 2023-10-15
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-foreground">Gitea Mirror Quick Start Guide</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">This guide will help you get Gitea Mirror up and running quickly.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before you begin, make sure you have:
|
||||||
|
|
||||||
|
1. <span class="font-semibold text-foreground">A GitHub account with a personal access token</span>
|
||||||
|
2. <span class="font-semibold text-foreground">A Gitea instance with an access token</span>
|
||||||
|
3. <span class="font-semibold text-foreground">Docker and docker-compose (recommended) or Node.js 18+ installed</span>
|
||||||
|
|
||||||
|
## Installation Options
|
||||||
|
|
||||||
|
Choose the installation method that works best for your environment.
|
||||||
|
|
||||||
|
### Using Docker (Recommended)
|
||||||
|
|
||||||
|
Docker provides the easiest way to get started with minimal configuration.
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||||
|
cd gitea-mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the application in production mode:
|
||||||
|
```bash
|
||||||
|
docker-compose --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access the application at [http://localhost:4321](http://localhost:4321)
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
|
||||||
|
If you prefer to run the application directly on your system:
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/arunavo4/gitea-mirror.git
|
||||||
|
cd gitea-mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the quick setup script:
|
||||||
|
```bash
|
||||||
|
pnpm setup
|
||||||
|
```
|
||||||
|
This installs dependencies and initializes the database.
|
||||||
|
|
||||||
|
3. Choose how to run the application:
|
||||||
|
|
||||||
|
**Development Mode:**
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production Mode:**
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application at [http://localhost:4321](http://localhost:4321)
|
||||||
|
|
||||||
|
## Initial Configuration
|
||||||
|
|
||||||
|
Follow these steps to configure Gitea Mirror for first use:
|
||||||
|
|
||||||
|
1. **Create Admin Account**
|
||||||
|
- Upon first access, you'll be prompted to create an admin account
|
||||||
|
- Choose a secure username and password
|
||||||
|
- This will be your administrator account
|
||||||
|
|
||||||
|
2. **Configure GitHub Connection**
|
||||||
|
- Navigate to the Configuration page
|
||||||
|
- Enter your GitHub username
|
||||||
|
- Enter your GitHub personal access token
|
||||||
|
- Select which repositories to mirror (all, starred, organizations)
|
||||||
|
- Configure repository filtering options
|
||||||
|
|
||||||
|
3. **Configure Gitea Connection**
|
||||||
|
- Enter your Gitea server URL
|
||||||
|
- Enter your Gitea access token
|
||||||
|
- Configure organization and visibility settings
|
||||||
|
|
||||||
|
4. **Set Up Scheduling (Optional)**
|
||||||
|
- Enable automatic mirroring if desired
|
||||||
|
- Set the mirroring interval (in seconds)
|
||||||
|
|
||||||
|
5. **Save Configuration**
|
||||||
|
- Click the "Save" button to store your settings
|
||||||
|
|
||||||
|
## Performing Your First Mirror
|
||||||
|
|
||||||
|
After completing the configuration, you can start mirroring repositories:
|
||||||
|
|
||||||
|
1. Click "Import GitHub Data" to fetch repositories from GitHub
|
||||||
|
2. Go to the Repositories page to view your imported repositories
|
||||||
|
3. Select the repositories you want to mirror
|
||||||
|
4. Click "Mirror Selected" to start the mirroring process
|
||||||
|
5. Monitor the progress on the Activity page
|
||||||
|
6. You'll receive toast notifications about the success or failure of operations
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If you encounter any issues:
|
||||||
|
|
||||||
|
- Check the Activity Log for detailed error messages
|
||||||
|
- Verify your GitHub and Gitea tokens have the correct permissions
|
||||||
|
- Ensure your Gitea instance is accessible from the machine running Gitea Mirror
|
||||||
|
- For Docker installations, check container logs with `docker logs gitea-mirror`
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After your initial setup:
|
||||||
|
|
||||||
|
- Explore the dashboard for an overview of your mirroring status
|
||||||
|
- Set up automatic mirroring schedules for hands-off operation
|
||||||
|
- Configure organization mirroring for team repositories
|
||||||
16
src/data/Sidebar.ts
Normal file
16
src/data/Sidebar.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
GitFork,
|
||||||
|
Settings,
|
||||||
|
Activity,
|
||||||
|
Building2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { SidebarItem } from "@/types/Sidebar";
|
||||||
|
|
||||||
|
export const links: SidebarItem[] = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/repositories", label: "Repositories", icon: GitFork },
|
||||||
|
{ href: "/organizations", label: "Organizations", icon: Building2 },
|
||||||
|
{ href: "/config", label: "Configuration", icon: Settings },
|
||||||
|
{ href: "/activity", label: "Activity Log", icon: Activity },
|
||||||
|
];
|
||||||
150
src/hooks/useAuth.ts
Normal file
150
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
type Context,
|
||||||
|
} from "react";
|
||||||
|
import { authApi } from "@/lib/api";
|
||||||
|
import type { ExtendedUser } from "@/types/user";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: ExtendedUser | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
register: (
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>; // Added refreshUser function
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext: Context<AuthContextType | undefined> = createContext<
|
||||||
|
AuthContextType | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<ExtendedUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Function to refetch the user data
|
||||||
|
const refreshUser = async () => {
|
||||||
|
// not using loading state to keep the ui seamless and refresh the data in bg
|
||||||
|
// setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const user = await authApi.getCurrentUser();
|
||||||
|
|
||||||
|
console.log("User data refreshed:", user);
|
||||||
|
|
||||||
|
setUser(user);
|
||||||
|
} catch (err: any) {
|
||||||
|
setUser(null);
|
||||||
|
console.error("Failed to refresh user data", err);
|
||||||
|
} finally {
|
||||||
|
// setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Automatically check the user status when the app loads
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const user = await authApi.getCurrentUser();
|
||||||
|
|
||||||
|
console.log("User data fetched:", user);
|
||||||
|
|
||||||
|
setUser(user);
|
||||||
|
} catch (err: any) {
|
||||||
|
setUser(null);
|
||||||
|
|
||||||
|
// Redirect user based on error
|
||||||
|
if (err?.message === "No users found") {
|
||||||
|
window.location.href = "/signup";
|
||||||
|
} else {
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
console.error("Auth check failed", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const user = await authApi.login(username, password);
|
||||||
|
setUser(user);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Login failed");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const user = await authApi.register(username, email, password);
|
||||||
|
setUser(user);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Registration failed");
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
setUser(null);
|
||||||
|
window.location.href = "/login";
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Logout error:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the context value with the added refreshUser function
|
||||||
|
const contextValue = {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the provider with the context value
|
||||||
|
return React.createElement(
|
||||||
|
AuthContext.Provider,
|
||||||
|
{ value: contextValue },
|
||||||
|
children
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
59
src/hooks/useFilterParams.ts
Normal file
59
src/hooks/useFilterParams.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { FilterParams } from "@/types/filter";
|
||||||
|
|
||||||
|
const FILTER_KEYS: (keyof FilterParams)[] = [
|
||||||
|
"searchTerm",
|
||||||
|
"status",
|
||||||
|
"membershipRole",
|
||||||
|
"owner",
|
||||||
|
"organization",
|
||||||
|
"type",
|
||||||
|
"name",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useFilterParams = (
|
||||||
|
defaultFilters: FilterParams,
|
||||||
|
debounceDelay = 300
|
||||||
|
) => {
|
||||||
|
const getInitialFilter = (): FilterParams => {
|
||||||
|
if (typeof window === "undefined") return defaultFilters;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const result: FilterParams = { ...defaultFilters };
|
||||||
|
|
||||||
|
FILTER_KEYS.forEach((key) => {
|
||||||
|
const value = params.get(key);
|
||||||
|
if (value !== null) {
|
||||||
|
(result as any)[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<FilterParams>(() => getInitialFilter());
|
||||||
|
|
||||||
|
// Debounced URL update
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
FILTER_KEYS.forEach((key) => {
|
||||||
|
const value = filter[key];
|
||||||
|
if (value) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||||
|
window.history.replaceState({}, "", newUrl);
|
||||||
|
}, debounceDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(handler); // Cleanup on unmount or when `filter` changes
|
||||||
|
}, [filter, debounceDelay]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
setFilter,
|
||||||
|
};
|
||||||
|
};
|
||||||
83
src/hooks/useMirror.ts
Normal file
83
src/hooks/useMirror.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { mirrorApi } from '@/lib/api';
|
||||||
|
import type { MirrorJob } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
export function useMirror() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentJob, setCurrentJob] = useState<MirrorJob | null>(null);
|
||||||
|
const [jobs, setJobs] = useState<MirrorJob[]>([]);
|
||||||
|
|
||||||
|
const startMirror = async (configId: string, repositoryIds?: string[]) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const job = await mirrorApi.startMirror(configId, repositoryIds);
|
||||||
|
setCurrentJob(job);
|
||||||
|
return job;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start mirroring');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMirrorJobs = async (configId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fetchedJobs = await mirrorApi.getMirrorJobs(configId);
|
||||||
|
setJobs(fetchedJobs);
|
||||||
|
return fetchedJobs;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch mirror jobs');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMirrorJob = async (jobId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const job = await mirrorApi.getMirrorJob(jobId);
|
||||||
|
setCurrentJob(job);
|
||||||
|
return job;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch mirror job');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelMirrorJob = async (jobId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await mirrorApi.cancelMirrorJob(jobId);
|
||||||
|
if (result.success && currentJob?.id === jobId) {
|
||||||
|
setCurrentJob({ ...currentJob, status: 'failed' });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to cancel mirror job');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
currentJob,
|
||||||
|
jobs,
|
||||||
|
startMirror,
|
||||||
|
getMirrorJobs,
|
||||||
|
getMirrorJob,
|
||||||
|
cancelMirrorJob,
|
||||||
|
};
|
||||||
|
}
|
||||||
54
src/hooks/useSEE.ts
Normal file
54
src/hooks/useSEE.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import type { MirrorJob } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
interface UseSSEOptions {
|
||||||
|
userId?: string;
|
||||||
|
onMessage: (data: MirrorJob) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSSE = ({ userId, onMessage }: UseSSEOptions) => {
|
||||||
|
const [connected, setConnected] = useState<boolean>(false);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
|
||||||
|
// Update the ref when onMessage changes
|
||||||
|
useEffect(() => {
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`/api/sse?userId=${userId}`);
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const parsedMessage: MirrorJob = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// console.log("Received new log:", parsedMessage);
|
||||||
|
|
||||||
|
onMessageRef.current(parsedMessage); // Use ref instead of prop directly
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing message:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = handleMessage;
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
console.log(`Connected to SSE for user: ${userId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.error("SSE connection error");
|
||||||
|
setConnected(false);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [userId]); // Only depends on userId now
|
||||||
|
|
||||||
|
return { connected };
|
||||||
|
};
|
||||||
102
src/hooks/useSyncRepo.ts
Normal file
102
src/hooks/useSyncRepo.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
interface UseRepoSyncOptions {
|
||||||
|
userId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
interval?: number;
|
||||||
|
lastSync?: Date | null;
|
||||||
|
nextSync?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRepoSync({
|
||||||
|
userId,
|
||||||
|
enabled = true,
|
||||||
|
interval = 3600,
|
||||||
|
lastSync,
|
||||||
|
nextSync,
|
||||||
|
}: UseRepoSyncOptions) {
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const { refreshUser } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !userId) {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert possible nextSync types to Date
|
||||||
|
const getNextSyncDate = () => {
|
||||||
|
if (!nextSync) return null;
|
||||||
|
if (nextSync instanceof Date) return nextSync;
|
||||||
|
return new Date(nextSync); // Handles strings and numbers
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastSyncDate = () => {
|
||||||
|
if (!lastSync) return null;
|
||||||
|
if (lastSync instanceof Date) return lastSync;
|
||||||
|
return new Date(lastSync);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTimeToSync = () => {
|
||||||
|
const nextSyncDate = getNextSyncDate();
|
||||||
|
if (!nextSyncDate) return true; // No nextSync means sync immediately
|
||||||
|
|
||||||
|
const currentTime = new Date();
|
||||||
|
return currentTime >= nextSyncDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
try {
|
||||||
|
console.log("Attempting to sync...");
|
||||||
|
const response = await fetch("/api/job/schedule-sync-repo", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Sync failed:", await response.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshUser(); // refresh user data to get latest sync times. this can be taken from the schedule-sync-repo response but might not be reliable in cases of errors
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log("Sync successful:", result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Sync failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if sync is overdue when the component mounts or interval passes
|
||||||
|
if (isTimeToSync()) {
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodically check if it's time to sync
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
if (isTimeToSync()) {
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
}, interval * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
interval,
|
||||||
|
userId,
|
||||||
|
nextSync instanceof Date ? nextSync.getTime() : nextSync,
|
||||||
|
lastSync instanceof Date ? lastSync.getTime() : lastSync,
|
||||||
|
]);
|
||||||
|
}
|
||||||
21
src/layouts/main.astro
Normal file
21
src/layouts/main.astro
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
import '../styles/docs.css';
|
||||||
|
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||||
|
|
||||||
|
// Accept title as a prop with a default value
|
||||||
|
const { title = 'Gitea Mirror' } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>{title}</title>
|
||||||
|
<ThemeScript />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<slot />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
90
src/lib/api.ts
Normal file
90
src/lib/api.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Base API URL
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
// Helper function for API requests
|
||||||
|
async function apiRequest<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({
|
||||||
|
message: "An unknown error occurred",
|
||||||
|
}));
|
||||||
|
throw new Error(error.message || "An unknown error occurred");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth API
|
||||||
|
export const authApi = {
|
||||||
|
login: async (username: string, password: string) => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include", // Send cookies
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Login failed");
|
||||||
|
return await res.json(); // returns user
|
||||||
|
},
|
||||||
|
|
||||||
|
register: async (username: string, email: string, password: string) => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ username, email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Registration failed");
|
||||||
|
return await res.json(); // returns user
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentUser: async () => {
|
||||||
|
const res = await fetch(`${API_BASE}/auth`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include", // Send cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Not authenticated");
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// GitHub API
|
||||||
|
export const githubApi = {
|
||||||
|
testConnection: (token: string) =>
|
||||||
|
apiRequest<{ success: boolean }>("/github/test-connection", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gitea API
|
||||||
|
export const giteaApi = {
|
||||||
|
testConnection: (url: string, token: string) =>
|
||||||
|
apiRequest<{ success: boolean }>("/gitea/test-connection", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url, token }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
28
src/lib/config.ts
Normal file
28
src/lib/config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Application configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Environment variables
|
||||||
|
export const ENV = {
|
||||||
|
// Node environment (development, production, test)
|
||||||
|
NODE_ENV: process.env.NODE_ENV || "development",
|
||||||
|
|
||||||
|
// Database URL - use SQLite by default
|
||||||
|
get DATABASE_URL() {
|
||||||
|
// If explicitly set, use the provided DATABASE_URL
|
||||||
|
if (process.env.DATABASE_URL) {
|
||||||
|
return process.env.DATABASE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use the default database
|
||||||
|
return "sqlite://data/gitea-mirror.db";
|
||||||
|
},
|
||||||
|
|
||||||
|
// JWT secret for authentication
|
||||||
|
JWT_SECRET:
|
||||||
|
process.env.JWT_SECRET || "your-secret-key-change-this-in-production",
|
||||||
|
|
||||||
|
// Server host and port
|
||||||
|
HOST: process.env.HOST || "localhost",
|
||||||
|
PORT: parseInt(process.env.PORT || "3000", 10),
|
||||||
|
};
|
||||||
177
src/lib/db/index.ts
Normal file
177
src/lib/db/index.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createClient } from "@libsql/client";
|
||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
import path from "path";
|
||||||
|
import { configSchema } from "./schema";
|
||||||
|
|
||||||
|
// Define the database URL - for development we'll use a local SQLite file
|
||||||
|
const dataDir = path.join(process.cwd(), "data");
|
||||||
|
const dbUrl =
|
||||||
|
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
|
||||||
|
|
||||||
|
// Create a client connection to the database
|
||||||
|
export const client = createClient({ url: dbUrl });
|
||||||
|
|
||||||
|
// Create a drizzle instance
|
||||||
|
export const db = drizzle(client);
|
||||||
|
|
||||||
|
// Define the tables
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
username: text("username").notNull(),
|
||||||
|
password: text("password").notNull(),
|
||||||
|
email: text("email").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const githubSchema = configSchema.shape.githubConfig;
|
||||||
|
const giteaSchema = configSchema.shape.giteaConfig;
|
||||||
|
const scheduleSchema = configSchema.shape.scheduleConfig;
|
||||||
|
|
||||||
|
export const configs = sqliteTable("configs", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||||
|
|
||||||
|
githubConfig: text("github_config", { mode: "json" })
|
||||||
|
.$type<z.infer<typeof githubSchema>>()
|
||||||
|
.notNull(),
|
||||||
|
|
||||||
|
giteaConfig: text("gitea_config", { mode: "json" })
|
||||||
|
.$type<z.infer<typeof giteaSchema>>()
|
||||||
|
.notNull(),
|
||||||
|
|
||||||
|
include: text("include", { mode: "json" })
|
||||||
|
.$type<string[]>()
|
||||||
|
.notNull()
|
||||||
|
.default(["*"]),
|
||||||
|
|
||||||
|
exclude: text("exclude", { mode: "json" })
|
||||||
|
.$type<string[]>()
|
||||||
|
.notNull()
|
||||||
|
.default([]),
|
||||||
|
|
||||||
|
scheduleConfig: text("schedule_config", { mode: "json" })
|
||||||
|
.$type<z.infer<typeof scheduleSchema>>()
|
||||||
|
.notNull(),
|
||||||
|
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const repositories = sqliteTable("repositories", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
configId: text("config_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => configs.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
fullName: text("full_name").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
cloneUrl: text("clone_url").notNull(),
|
||||||
|
owner: text("owner").notNull(),
|
||||||
|
organization: text("organization"),
|
||||||
|
mirroredLocation: text("mirrored_location").default(""),
|
||||||
|
|
||||||
|
isPrivate: integer("is_private", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false),
|
||||||
|
forkedFrom: text("forked_from"),
|
||||||
|
|
||||||
|
hasIssues: integer("has_issues", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
isStarred: integer("is_starred", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
isArchived: integer("is_archived", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
|
||||||
|
size: integer("size").notNull().default(0),
|
||||||
|
hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false),
|
||||||
|
hasSubmodules: integer("has_submodules", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
|
||||||
|
defaultBranch: text("default_branch").notNull(),
|
||||||
|
visibility: text("visibility").notNull().default("public"),
|
||||||
|
|
||||||
|
status: text("status").notNull().default("imported"),
|
||||||
|
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mirrorJobs = sqliteTable("mirror_jobs", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
repositoryId: text("repository_id"),
|
||||||
|
repositoryName: text("repository_name"),
|
||||||
|
organizationId: text("organization_id"),
|
||||||
|
organizationName: text("organization_name"),
|
||||||
|
details: text("details"),
|
||||||
|
status: text("status").notNull().default("imported"),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
timestamp: integer("timestamp", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const organizations = sqliteTable("organizations", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
configId: text("config_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => configs.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
|
||||||
|
avatarUrl: text("avatar_url").notNull(),
|
||||||
|
|
||||||
|
membershipRole: text("membership_role").notNull().default("member"),
|
||||||
|
|
||||||
|
isIncluded: integer("is_included", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
|
||||||
|
status: text("status").notNull().default("imported"),
|
||||||
|
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
|
||||||
|
errorMessage: text("error_message"),
|
||||||
|
|
||||||
|
repositoryCount: integer("repository_count").notNull().default(0),
|
||||||
|
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.default(new Date()),
|
||||||
|
});
|
||||||
27
src/lib/db/migrations/add-mirrored-location.ts
Normal file
27
src/lib/db/migrations/add-mirrored-location.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { client } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration script to add the mirrored_location column to the repositories table
|
||||||
|
*/
|
||||||
|
export async function addMirroredLocationColumn() {
|
||||||
|
try {
|
||||||
|
console.log("Starting migration: Adding mirrored_location column to repositories table");
|
||||||
|
|
||||||
|
// Check if the column already exists
|
||||||
|
const tableInfo = await client.execute(`PRAGMA table_info(repositories)`);
|
||||||
|
const columnExists = tableInfo.rows.some((row: any) => row.name === "mirrored_location");
|
||||||
|
|
||||||
|
if (columnExists) {
|
||||||
|
console.log("Column mirrored_location already exists, skipping migration");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the mirrored_location column
|
||||||
|
await client.execute(`ALTER TABLE repositories ADD COLUMN mirrored_location TEXT DEFAULT ''`);
|
||||||
|
|
||||||
|
console.log("Migration completed successfully: mirrored_location column added");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Migration failed:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/lib/db/schema.sql
Normal file
75
src/lib/db/schema.sql
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Configurations table
|
||||||
|
CREATE TABLE IF NOT EXISTS configs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
github_config TEXT NOT NULL,
|
||||||
|
gitea_config TEXT NOT NULL,
|
||||||
|
schedule_config TEXT NOT NULL,
|
||||||
|
include TEXT NOT NULL,
|
||||||
|
exclude TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Repositories table
|
||||||
|
CREATE TABLE IF NOT EXISTS repositories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
is_private BOOLEAN NOT NULL,
|
||||||
|
is_fork BOOLEAN NOT NULL,
|
||||||
|
owner TEXT NOT NULL,
|
||||||
|
organization TEXT,
|
||||||
|
mirrored_location TEXT DEFAULT '',
|
||||||
|
has_issues BOOLEAN NOT NULL,
|
||||||
|
is_starred BOOLEAN NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
last_mirrored DATETIME,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Organizations table
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
is_included BOOLEAN NOT NULL,
|
||||||
|
repository_count INTEGER NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Mirror jobs table
|
||||||
|
CREATE TABLE IF NOT EXISTS mirror_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
config_id TEXT NOT NULL,
|
||||||
|
repository_id TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
completed_at DATETIME,
|
||||||
|
log TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
142
src/lib/db/schema.ts
Normal file
142
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||||
|
import { membershipRoleEnum } from "@/types/organizations";
|
||||||
|
|
||||||
|
// User schema
|
||||||
|
export const userSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
username: z.string().min(3),
|
||||||
|
password: z.string().min(8).optional(), // Hashed password
|
||||||
|
email: z.string().email(),
|
||||||
|
createdAt: z.date().default(() => new Date()),
|
||||||
|
updatedAt: z.date().default(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
|
// Configuration schema
|
||||||
|
export const configSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
userId: z.string().uuid(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
githubConfig: z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
token: z.string().optional(),
|
||||||
|
skipForks: z.boolean().default(false),
|
||||||
|
privateRepositories: z.boolean().default(false),
|
||||||
|
mirrorIssues: z.boolean().default(false),
|
||||||
|
mirrorStarred: z.boolean().default(false),
|
||||||
|
useSpecificUser: z.boolean().default(false),
|
||||||
|
singleRepo: z.string().optional(),
|
||||||
|
includeOrgs: z.array(z.string()).default([]),
|
||||||
|
excludeOrgs: z.array(z.string()).default([]),
|
||||||
|
mirrorPublicOrgs: z.boolean().default(false),
|
||||||
|
publicOrgs: z.array(z.string()).default([]),
|
||||||
|
preserveOrgStructure: z.boolean().default(false),
|
||||||
|
skipStarredIssues: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
giteaConfig: z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
url: z.string().url(),
|
||||||
|
token: z.string().min(1),
|
||||||
|
organization: z.string().optional(),
|
||||||
|
visibility: z.enum(["public", "private", "limited"]).default("public"),
|
||||||
|
starredReposOrg: z.string().default("github"),
|
||||||
|
}),
|
||||||
|
include: z.array(z.string()).default(["*"]),
|
||||||
|
exclude: z.array(z.string()).default([]),
|
||||||
|
scheduleConfig: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
interval: z.number().min(1).default(3600), // in seconds
|
||||||
|
lastRun: z.date().optional(),
|
||||||
|
nextRun: z.date().optional(),
|
||||||
|
}),
|
||||||
|
createdAt: z.date().default(() => new Date()),
|
||||||
|
updatedAt: z.date().default(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
|
||||||
|
// Repository schema
|
||||||
|
export const repositorySchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
configId: z.string().uuid(),
|
||||||
|
|
||||||
|
name: z.string().min(1),
|
||||||
|
fullName: z.string().min(1),
|
||||||
|
url: z.string().url(),
|
||||||
|
cloneUrl: z.string().url(),
|
||||||
|
|
||||||
|
owner: z.string().min(1),
|
||||||
|
organization: z.string().optional(),
|
||||||
|
|
||||||
|
isPrivate: z.boolean().default(false),
|
||||||
|
isForked: z.boolean().default(false),
|
||||||
|
forkedFrom: z.string().optional(),
|
||||||
|
|
||||||
|
hasIssues: z.boolean().default(false),
|
||||||
|
isStarred: z.boolean().default(false),
|
||||||
|
isArchived: z.boolean().default(false),
|
||||||
|
|
||||||
|
size: z.number(),
|
||||||
|
hasLFS: z.boolean().default(false),
|
||||||
|
hasSubmodules: z.boolean().default(false),
|
||||||
|
|
||||||
|
defaultBranch: z.string(),
|
||||||
|
visibility: repositoryVisibilityEnum.default("public"),
|
||||||
|
|
||||||
|
status: repoStatusEnum.default("imported"),
|
||||||
|
lastMirrored: z.date().optional(),
|
||||||
|
errorMessage: z.string().optional(),
|
||||||
|
|
||||||
|
mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored
|
||||||
|
|
||||||
|
createdAt: z.date().default(() => new Date()),
|
||||||
|
updatedAt: z.date().default(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Repository = z.infer<typeof repositorySchema>;
|
||||||
|
|
||||||
|
// Mirror job schema
|
||||||
|
export const mirrorJobSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
repositoryId: z.string().uuid().optional(),
|
||||||
|
repositoryName: z.string().optional(),
|
||||||
|
organizationId: z.string().uuid().optional(),
|
||||||
|
organizationName: z.string().optional(),
|
||||||
|
details: z.string().optional(),
|
||||||
|
status: repoStatusEnum.default("imported"),
|
||||||
|
message: z.string(),
|
||||||
|
timestamp: z.date().default(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
|
||||||
|
|
||||||
|
// Organization schema
|
||||||
|
export const organizationSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
userId: z.string().uuid().optional(),
|
||||||
|
configId: z.string().uuid(),
|
||||||
|
|
||||||
|
avatarUrl: z.string().url(),
|
||||||
|
|
||||||
|
name: z.string().min(1),
|
||||||
|
|
||||||
|
membershipRole: membershipRoleEnum.default("member"),
|
||||||
|
|
||||||
|
isIncluded: z.boolean().default(false),
|
||||||
|
|
||||||
|
status: repoStatusEnum.default("imported"),
|
||||||
|
lastMirrored: z.date().optional(),
|
||||||
|
errorMessage: z.string().optional(),
|
||||||
|
|
||||||
|
repositoryCount: z.number().default(0),
|
||||||
|
|
||||||
|
createdAt: z.date().default(() => new Date()),
|
||||||
|
updatedAt: z.date().default(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Organization = z.infer<typeof organizationSchema>;
|
||||||
962
src/lib/gitea.ts
Normal file
962
src/lib/gitea.ts
Normal file
@@ -0,0 +1,962 @@
|
|||||||
|
import {
|
||||||
|
repoStatusEnum,
|
||||||
|
type RepositoryVisibility,
|
||||||
|
type RepoStatus,
|
||||||
|
} from "@/types/Repository";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
import type { Config } from "@/types/config";
|
||||||
|
import type { Organization, Repository } from "./db/schema";
|
||||||
|
import superagent from "superagent";
|
||||||
|
import { createMirrorJob } from "./helpers";
|
||||||
|
import { db, organizations, repositories } from "./db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const getGiteaRepoOwner = ({
|
||||||
|
config,
|
||||||
|
repository,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
repository: Repository;
|
||||||
|
}): string => {
|
||||||
|
if (!config.githubConfig || !config.giteaConfig) {
|
||||||
|
throw new Error("GitHub or Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.giteaConfig.username) {
|
||||||
|
throw new Error("Gitea username is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the config has preserveOrgStructure set to true, then use the org name as the owner
|
||||||
|
if (config.githubConfig.preserveOrgStructure && repository.organization) {
|
||||||
|
return repository.organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the config has preserveOrgStructure set to false, then use the gitea username as the owner
|
||||||
|
return config.giteaConfig.username;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRepoPresentInGitea = async ({
|
||||||
|
config,
|
||||||
|
owner,
|
||||||
|
repoName,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
owner: string;
|
||||||
|
repoName: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the repository exists at the specified owner location
|
||||||
|
const response = await fetch(
|
||||||
|
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking if repo exists in Gitea:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if a repository exists in Gitea.
|
||||||
|
* First checks the recorded mirroredLocation, then falls back to the expected location.
|
||||||
|
*/
|
||||||
|
export const checkRepoLocation = async ({
|
||||||
|
config,
|
||||||
|
repository,
|
||||||
|
expectedOwner,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
repository: Repository;
|
||||||
|
expectedOwner: string;
|
||||||
|
}): Promise<{ present: boolean; actualOwner: string }> => {
|
||||||
|
// First check if we have a recorded mirroredLocation and if the repo exists there
|
||||||
|
if (repository.mirroredLocation && repository.mirroredLocation.trim() !== "") {
|
||||||
|
const [mirroredOwner] = repository.mirroredLocation.split('/');
|
||||||
|
if (mirroredOwner) {
|
||||||
|
const mirroredPresent = await isRepoPresentInGitea({
|
||||||
|
config,
|
||||||
|
owner: mirroredOwner,
|
||||||
|
repoName: repository.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mirroredPresent) {
|
||||||
|
console.log(`Repository found at recorded mirrored location: ${repository.mirroredLocation}`);
|
||||||
|
return { present: true, actualOwner: mirroredOwner };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found at the recorded location, check the expected location
|
||||||
|
const present = await isRepoPresentInGitea({
|
||||||
|
config,
|
||||||
|
owner: expectedOwner,
|
||||||
|
repoName: repository.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (present) {
|
||||||
|
return { present: true, actualOwner: expectedOwner };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository not found at any location
|
||||||
|
return { present: false, actualOwner: expectedOwner };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mirrorGithubRepoToGitea = async ({
|
||||||
|
octokit,
|
||||||
|
repository,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
repository: Repository;
|
||||||
|
config: Partial<Config>;
|
||||||
|
}): Promise<any> => {
|
||||||
|
try {
|
||||||
|
if (!config.userId || !config.githubConfig || !config.giteaConfig) {
|
||||||
|
throw new Error("github config and gitea config are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.giteaConfig.username) {
|
||||||
|
throw new Error("Gitea username is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExisting = await isRepoPresentInGitea({
|
||||||
|
config,
|
||||||
|
owner: config.giteaConfig.username,
|
||||||
|
repoName: repository.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isExisting) {
|
||||||
|
console.log(
|
||||||
|
`Repository ${repository.name} already exists in Gitea. Skipping migration.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Mirroring repository ${repository.name}`);
|
||||||
|
|
||||||
|
// Mark repos as "mirroring" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("mirroring"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for "mirroring" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Started mirroring repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} is now in the mirroring state.`,
|
||||||
|
status: "mirroring",
|
||||||
|
});
|
||||||
|
|
||||||
|
let cloneAddress = repository.cloneUrl;
|
||||||
|
|
||||||
|
// If the repository is private, inject the GitHub token into the clone URL
|
||||||
|
if (repository.isPrivate) {
|
||||||
|
if (!config.githubConfig.token) {
|
||||||
|
throw new Error(
|
||||||
|
"GitHub token is required to mirror private repositories."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneAddress = repository.cloneUrl.replace(
|
||||||
|
"https://",
|
||||||
|
`https://${config.githubConfig.token}@`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
|
||||||
|
|
||||||
|
const response = await superagent
|
||||||
|
.post(apiUrl)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.send({
|
||||||
|
clone_addr: cloneAddress,
|
||||||
|
repo_name: repository.name,
|
||||||
|
mirror: true,
|
||||||
|
private: repository.isPrivate,
|
||||||
|
repo_owner: config.giteaConfig.username,
|
||||||
|
description: "",
|
||||||
|
service: "git",
|
||||||
|
});
|
||||||
|
|
||||||
|
// clone issues
|
||||||
|
if (config.githubConfig.mirrorIssues) {
|
||||||
|
await mirrorGitRepoIssuesToGitea({
|
||||||
|
config,
|
||||||
|
octokit,
|
||||||
|
repository,
|
||||||
|
isRepoInOrg: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Repository ${repository.name} mirrored successfully`);
|
||||||
|
|
||||||
|
// Mark repos as "mirrored" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("mirrored"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
lastMirrored: new Date(),
|
||||||
|
errorMessage: null,
|
||||||
|
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for "mirrored" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Successfully mirrored repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} was mirrored to Gitea.`,
|
||||||
|
status: "mirrored",
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error while mirroring repository ${repository.name}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark repos as "failed" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("failed"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for failure
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId ?? "", // userId is going to be there anyways
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Failed to mirror repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} failed to mirror. Error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`Failed to mirror repository: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to mirror repository: An unknown error occurred.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getOrCreateGiteaOrg({
|
||||||
|
orgName,
|
||||||
|
orgId,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
orgId?: string; //db id
|
||||||
|
orgName: string;
|
||||||
|
config: Partial<Config>;
|
||||||
|
}): Promise<number> {
|
||||||
|
if (
|
||||||
|
!config.giteaConfig?.url ||
|
||||||
|
!config.giteaConfig?.token ||
|
||||||
|
!config.userId
|
||||||
|
) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgRes = await fetch(
|
||||||
|
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgRes.ok) {
|
||||||
|
const org = await orgRes.json();
|
||||||
|
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
organizationId: org.id,
|
||||||
|
organizationName: orgName,
|
||||||
|
status: "imported",
|
||||||
|
message: `Organization ${orgName} fetched successfully`,
|
||||||
|
details: `Organization ${orgName} was fetched from GitHub`,
|
||||||
|
});
|
||||||
|
return org.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: orgName,
|
||||||
|
full_name: `${orgName} Org`,
|
||||||
|
description: `Mirrored organization from GitHub ${orgName}`,
|
||||||
|
visibility: "public",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createRes.ok) {
|
||||||
|
throw new Error(`Failed to create Gitea org: ${await createRes.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
organizationName: orgName,
|
||||||
|
status: "imported",
|
||||||
|
message: `Organization ${orgName} created successfully`,
|
||||||
|
details: `Organization ${orgName} was created in Gitea`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrg = await createRes.json();
|
||||||
|
return newOrg.id;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error occurred in getOrCreateGiteaOrg.";
|
||||||
|
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
organizationId: orgId,
|
||||||
|
organizationName: orgName,
|
||||||
|
message: `Failed to create or fetch Gitea organization: ${orgName}`,
|
||||||
|
status: "failed",
|
||||||
|
details: `Error: ${errorMessage}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(`Error in getOrCreateGiteaOrg: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mirrorGitHubRepoToGiteaOrg({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
repository,
|
||||||
|
giteaOrgId,
|
||||||
|
orgName,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
config: Partial<Config>;
|
||||||
|
repository: Repository;
|
||||||
|
giteaOrgId: number;
|
||||||
|
orgName: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!config.giteaConfig?.url ||
|
||||||
|
!config.giteaConfig?.token ||
|
||||||
|
!config.userId
|
||||||
|
) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExisting = await isRepoPresentInGitea({
|
||||||
|
config,
|
||||||
|
owner: orgName,
|
||||||
|
repoName: repository.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isExisting) {
|
||||||
|
console.log(
|
||||||
|
`Repository ${repository.name} already exists in Gitea. Skipping migration.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Mirroring repository ${repository.name} to organization ${orgName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let cloneAddress = repository.cloneUrl;
|
||||||
|
|
||||||
|
if (repository.isPrivate) {
|
||||||
|
if (!config.githubConfig?.token) {
|
||||||
|
throw new Error(
|
||||||
|
"GitHub token is required to mirror private repositories."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
cloneAddress = repository.cloneUrl.replace(
|
||||||
|
"https://",
|
||||||
|
`https://${config.githubConfig.token}@`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark repos as "mirroring" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("mirroring"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for "mirroring" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Started mirroring repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} is now in the mirroring state.`,
|
||||||
|
status: "mirroring",
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
|
||||||
|
|
||||||
|
const migrateRes = await superagent
|
||||||
|
.post(apiUrl)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`)
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.send({
|
||||||
|
clone_addr: cloneAddress,
|
||||||
|
uid: giteaOrgId,
|
||||||
|
repo_name: repository.name,
|
||||||
|
mirror: true,
|
||||||
|
private: repository.isPrivate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clone issues
|
||||||
|
if (config.githubConfig?.mirrorIssues) {
|
||||||
|
await mirrorGitRepoIssuesToGitea({
|
||||||
|
config,
|
||||||
|
octokit,
|
||||||
|
repository,
|
||||||
|
isRepoInOrg: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Repository ${repository.name} mirrored successfully to organization ${orgName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark repos as "mirrored" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("mirrored"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
lastMirrored: new Date(),
|
||||||
|
errorMessage: null,
|
||||||
|
mirroredLocation: `${orgName}/${repository.name}`,
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
//create a mirror job
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Repository ${repository.name} mirrored successfully`,
|
||||||
|
details: `Repository ${repository.name} was mirrored to Gitea`,
|
||||||
|
status: "mirrored",
|
||||||
|
});
|
||||||
|
|
||||||
|
return migrateRes.body;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error while mirroring repository ${repository.name}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
// Mark repos as "failed" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("failed"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for failure
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId || "", // userId is going to be there anyways
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Failed to mirror repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} failed to mirror. Error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
status: "failed",
|
||||||
|
});
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`Failed to mirror repository: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to mirror repository: An unknown error occurred.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mirrorGitHubOrgRepoToGiteaOrg({
|
||||||
|
config,
|
||||||
|
octokit,
|
||||||
|
repository,
|
||||||
|
orgName,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
octokit: Octokit;
|
||||||
|
repository: Repository;
|
||||||
|
orgName: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaOrgId = await getOrCreateGiteaOrg({
|
||||||
|
orgName,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mirrorGitHubRepoToGiteaOrg({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
repository,
|
||||||
|
giteaOrgId,
|
||||||
|
orgName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`Failed to mirror repository: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to mirror repository: An unknown error occurred.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mirrorGitHubOrgToGitea({
|
||||||
|
organization,
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
organization: Organization;
|
||||||
|
octokit: Octokit;
|
||||||
|
config: Partial<Config>;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!config.userId ||
|
||||||
|
!config.id ||
|
||||||
|
!config.githubConfig?.token ||
|
||||||
|
!config.giteaConfig?.url
|
||||||
|
) {
|
||||||
|
throw new Error("Config, GitHub token and Gitea URL are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Mirroring organization ${organization.name}`);
|
||||||
|
|
||||||
|
//mark the org as "mirroring" in DB
|
||||||
|
await db
|
||||||
|
.update(organizations)
|
||||||
|
.set({
|
||||||
|
isIncluded: true,
|
||||||
|
status: repoStatusEnum.parse("mirroring"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(organizations.id, organization.id!));
|
||||||
|
|
||||||
|
// Append log for "mirroring" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
organizationId: organization.id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
message: `Started mirroring organization: ${organization.name}`,
|
||||||
|
details: `Organization ${organization.name} is now in the mirroring state.`,
|
||||||
|
status: repoStatusEnum.parse("mirroring"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const giteaOrgId = await getOrCreateGiteaOrg({
|
||||||
|
orgId: organization.id,
|
||||||
|
orgName: organization.name,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
//query the db with the org name and get the repos
|
||||||
|
const orgRepos = await db
|
||||||
|
.select()
|
||||||
|
.from(repositories)
|
||||||
|
.where(eq(repositories.organization, organization.name));
|
||||||
|
|
||||||
|
for (const repo of orgRepos) {
|
||||||
|
await mirrorGitHubRepoToGiteaOrg({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
repository: {
|
||||||
|
...repo,
|
||||||
|
status: repo.status as RepoStatus,
|
||||||
|
visibility: repo.visibility as RepositoryVisibility,
|
||||||
|
lastMirrored: repo.lastMirrored ?? undefined,
|
||||||
|
errorMessage: repo.errorMessage ?? undefined,
|
||||||
|
organization: repo.organization ?? undefined,
|
||||||
|
forkedFrom: repo.forkedFrom ?? undefined,
|
||||||
|
mirroredLocation: repo.mirroredLocation || "",
|
||||||
|
},
|
||||||
|
giteaOrgId,
|
||||||
|
orgName: organization.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Organization ${organization.name} mirrored successfully`);
|
||||||
|
|
||||||
|
// Mark org as "mirrored" in DB
|
||||||
|
await db
|
||||||
|
.update(organizations)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("mirrored"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
lastMirrored: new Date(),
|
||||||
|
errorMessage: null,
|
||||||
|
})
|
||||||
|
.where(eq(organizations.id, organization.id!));
|
||||||
|
|
||||||
|
// Append log for "mirrored" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
organizationId: organization.id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
message: `Successfully mirrored organization: ${organization.name}`,
|
||||||
|
details: `Organization ${organization.name} was mirrored to Gitea.`,
|
||||||
|
status: repoStatusEnum.parse("mirrored"),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error while mirroring organization ${organization.name}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark org as "failed" in DB
|
||||||
|
await db
|
||||||
|
.update(organizations)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("failed"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
})
|
||||||
|
.where(eq(organizations.id, organization.id!));
|
||||||
|
|
||||||
|
// Append log for failure
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId || "", // userId is going to be there anyways
|
||||||
|
organizationId: organization.id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
message: `Failed to mirror organization: ${organization.name}`,
|
||||||
|
details: `Organization ${organization.name} failed to mirror. Error: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
status: repoStatusEnum.parse("failed"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`Failed to mirror repository: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to mirror repository: An unknown error occurred.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncGiteaRepo = async ({
|
||||||
|
config,
|
||||||
|
repository,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
repository: Repository;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!config.userId ||
|
||||||
|
!config.giteaConfig?.url ||
|
||||||
|
!config.giteaConfig?.token ||
|
||||||
|
!config.giteaConfig?.username
|
||||||
|
) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Syncing repository ${repository.name}`);
|
||||||
|
|
||||||
|
// Mark repo as "syncing" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("syncing"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for "syncing" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Started syncing repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} is now in the syncing state.`,
|
||||||
|
status: repoStatusEnum.parse("syncing"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the expected owner based on current config
|
||||||
|
const repoOwner = getGiteaRepoOwner({ config, repository });
|
||||||
|
|
||||||
|
// Check if repo exists at the expected location or alternate location
|
||||||
|
const { present, actualOwner } = await checkRepoLocation({
|
||||||
|
config,
|
||||||
|
repository,
|
||||||
|
expectedOwner: repoOwner
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!present) {
|
||||||
|
throw new Error(`Repository ${repository.name} not found in Gitea at any expected location`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the actual owner where the repo was found
|
||||||
|
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${actualOwner}/${repository.name}/mirror-sync`;
|
||||||
|
|
||||||
|
const response = await superagent
|
||||||
|
.post(apiUrl)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`);
|
||||||
|
|
||||||
|
// Mark repo as "synced" in DB
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("synced"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
lastMirrored: new Date(),
|
||||||
|
errorMessage: null,
|
||||||
|
mirroredLocation: `${actualOwner}/${repository.name}`,
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for "synced" status
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Successfully synced repository: ${repository.name}`,
|
||||||
|
details: `Repository ${repository.name} was synced with Gitea.`,
|
||||||
|
status: repoStatusEnum.parse("synced"),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Repository ${repository.name} synced successfully`);
|
||||||
|
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error while syncing repository ${repository.name}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional: update repo with error status
|
||||||
|
await db
|
||||||
|
.update(repositories)
|
||||||
|
.set({
|
||||||
|
status: repoStatusEnum.parse("failed"),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
errorMessage: (error as Error).message,
|
||||||
|
})
|
||||||
|
.where(eq(repositories.id, repository.id!));
|
||||||
|
|
||||||
|
// Append log for "error" status
|
||||||
|
if (config.userId && repository.id && repository.name) {
|
||||||
|
await createMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
repositoryName: repository.name,
|
||||||
|
message: `Failed to sync repository: ${repository.name}`,
|
||||||
|
details: (error as Error).message,
|
||||||
|
status: repoStatusEnum.parse("failed"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw new Error(`Failed to sync repository: ${error.message}`);
|
||||||
|
}
|
||||||
|
throw new Error("Failed to sync repository: An unknown error occurred.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mirrorGitRepoIssuesToGitea = async ({
|
||||||
|
config,
|
||||||
|
octokit,
|
||||||
|
repository,
|
||||||
|
isRepoInOrg,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
octokit: Octokit;
|
||||||
|
repository: Repository;
|
||||||
|
isRepoInOrg: boolean;
|
||||||
|
}) => {
|
||||||
|
//things covered here are- issue, title, body, labels, comments and assignees
|
||||||
|
if (
|
||||||
|
!config.githubConfig?.token ||
|
||||||
|
!config.giteaConfig?.token ||
|
||||||
|
!config.giteaConfig?.url ||
|
||||||
|
!config.giteaConfig?.username
|
||||||
|
) {
|
||||||
|
throw new Error("Missing GitHub or Gitea configuration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoOrigin = isRepoInOrg
|
||||||
|
? repository.organization
|
||||||
|
: config.githubConfig.username;
|
||||||
|
|
||||||
|
const [owner, repo] = repository.fullName.split("/");
|
||||||
|
|
||||||
|
// Fetch GitHub issues
|
||||||
|
const issues = await octokit.paginate(
|
||||||
|
octokit.rest.issues.listForRepo,
|
||||||
|
{
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
state: "all",
|
||||||
|
per_page: 100,
|
||||||
|
},
|
||||||
|
(res) => res.data
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Mirroring ${issues.length} issues from ${repository.fullName}`);
|
||||||
|
|
||||||
|
// Get existing labels from Gitea
|
||||||
|
const giteaLabelsRes = await superagent
|
||||||
|
.get(
|
||||||
|
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`
|
||||||
|
)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`);
|
||||||
|
|
||||||
|
const giteaLabels = giteaLabelsRes.body;
|
||||||
|
const labelMap = new Map<string, number>(
|
||||||
|
giteaLabels.map((label: any) => [label.name, label.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
if ((issue as any).pull_request) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubLabelNames =
|
||||||
|
issue.labels
|
||||||
|
?.map((l) => (typeof l === "string" ? l : l.name))
|
||||||
|
.filter((l): l is string => !!l) || [];
|
||||||
|
|
||||||
|
const giteaLabelIds: number[] = [];
|
||||||
|
|
||||||
|
// Resolve or create labels in Gitea
|
||||||
|
for (const name of githubLabelNames) {
|
||||||
|
if (labelMap.has(name)) {
|
||||||
|
giteaLabelIds.push(labelMap.get(name)!);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const created = await superagent
|
||||||
|
.post(
|
||||||
|
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`
|
||||||
|
)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`)
|
||||||
|
.send({ name, color: "#ededed" }); // Default color
|
||||||
|
|
||||||
|
labelMap.set(name, created.body.id);
|
||||||
|
giteaLabelIds.push(created.body.id);
|
||||||
|
} catch (labelErr) {
|
||||||
|
console.error(
|
||||||
|
`Failed to create label "${name}" in Gitea: ${labelErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalAssignees =
|
||||||
|
issue.assignees && issue.assignees.length > 0
|
||||||
|
? `\n\nOriginally assigned to: ${issue.assignees
|
||||||
|
.map((a) => `@${a.login}`)
|
||||||
|
.join(", ")} on GitHub.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const issuePayload: any = {
|
||||||
|
title: issue.title,
|
||||||
|
body: `Originally created by @${
|
||||||
|
issue.user?.login
|
||||||
|
} on GitHub.${originalAssignees}\n\n${issue.body || ""}`,
|
||||||
|
closed: issue.state === "closed",
|
||||||
|
labels: giteaLabelIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdIssue = await superagent
|
||||||
|
.post(
|
||||||
|
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues`
|
||||||
|
)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`)
|
||||||
|
.send(issuePayload);
|
||||||
|
|
||||||
|
// Clone comments
|
||||||
|
const comments = await octokit.paginate(
|
||||||
|
octokit.rest.issues.listComments,
|
||||||
|
{
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
per_page: 100,
|
||||||
|
},
|
||||||
|
(res) => res.data
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
try {
|
||||||
|
await superagent
|
||||||
|
.post(
|
||||||
|
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues/${createdIssue.body.number}/comments`
|
||||||
|
)
|
||||||
|
.set("Authorization", `token ${config.giteaConfig.token}`)
|
||||||
|
.send({
|
||||||
|
body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`,
|
||||||
|
});
|
||||||
|
} catch (commentErr) {
|
||||||
|
console.error(
|
||||||
|
`Failed to copy comment to Gitea for issue "${issue.title}": ${
|
||||||
|
commentErr instanceof Error
|
||||||
|
? commentErr.message
|
||||||
|
: String(commentErr)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && (err as any).response) {
|
||||||
|
console.error(
|
||||||
|
`Failed to create issue "${issue.title}" in Gitea: ${err.message}`
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
`Response body: ${JSON.stringify((err as any).response.body)}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
`Failed to create issue "${issue.title}" in Gitea: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
265
src/lib/github.ts
Normal file
265
src/lib/github.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import type { GitOrg, MembershipRole } from "@/types/organizations";
|
||||||
|
import type { GitRepo, RepoStatus } from "@/types/Repository";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
import type { Config } from "@/types/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an authenticated Octokit instance
|
||||||
|
*/
|
||||||
|
export function createGitHubClient(token: string): Octokit {
|
||||||
|
return new Octokit({
|
||||||
|
auth: token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone a repository from GitHub
|
||||||
|
*/
|
||||||
|
export async function getGithubRepoCloneUrl({
|
||||||
|
octokit,
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
}): Promise<{ url: string; cloneUrl: string }> {
|
||||||
|
const { data } = await octokit.repos.get({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: data.html_url,
|
||||||
|
cloneUrl: data.clone_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user repositories from GitHub
|
||||||
|
* todo: need to handle pagination and apply more filters based on user config
|
||||||
|
*/
|
||||||
|
export async function getGithubRepositories({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
config: Partial<Config>;
|
||||||
|
}): Promise<GitRepo[]> {
|
||||||
|
try {
|
||||||
|
const repos = await octokit.paginate(
|
||||||
|
octokit.repos.listForAuthenticatedUser,
|
||||||
|
{ per_page: 100 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const includePrivate = config.githubConfig?.privateRepositories ?? false;
|
||||||
|
const skipForks = config.githubConfig?.skipForks ?? false;
|
||||||
|
|
||||||
|
const filteredRepos = repos.filter((repo) => {
|
||||||
|
const isPrivateAllowed = includePrivate || !repo.private;
|
||||||
|
const isForkAllowed = !skipForks || !repo.fork;
|
||||||
|
return isPrivateAllowed && isForkAllowed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRepos.map((repo) => ({
|
||||||
|
name: repo.name,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
url: repo.html_url,
|
||||||
|
cloneUrl: repo.clone_url,
|
||||||
|
|
||||||
|
owner: repo.owner.login,
|
||||||
|
organization:
|
||||||
|
repo.owner.type === "Organization" ? repo.owner.login : undefined,
|
||||||
|
|
||||||
|
isPrivate: repo.private,
|
||||||
|
isForked: repo.fork,
|
||||||
|
forkedFrom: (repo as typeof repo & { parent?: { full_name: string } })
|
||||||
|
.parent?.full_name,
|
||||||
|
|
||||||
|
hasIssues: repo.has_issues,
|
||||||
|
isStarred: false,
|
||||||
|
isArchived: repo.archived,
|
||||||
|
|
||||||
|
size: repo.size,
|
||||||
|
hasLFS: false,
|
||||||
|
hasSubmodules: false,
|
||||||
|
|
||||||
|
defaultBranch: repo.default_branch,
|
||||||
|
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||||
|
|
||||||
|
status: "imported",
|
||||||
|
lastMirrored: undefined,
|
||||||
|
errorMessage: undefined,
|
||||||
|
|
||||||
|
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
|
||||||
|
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error fetching repositories: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGithubStarredRepositories({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
config: Partial<Config>;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const starredRepos = await octokit.paginate(
|
||||||
|
octokit.activity.listReposStarredByAuthenticatedUser,
|
||||||
|
{
|
||||||
|
per_page: 100,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return starredRepos.map((repo) => ({
|
||||||
|
name: repo.name,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
url: repo.html_url,
|
||||||
|
cloneUrl: repo.clone_url,
|
||||||
|
|
||||||
|
owner: repo.owner.login,
|
||||||
|
organization:
|
||||||
|
repo.owner.type === "Organization" ? repo.owner.login : undefined,
|
||||||
|
|
||||||
|
isPrivate: repo.private,
|
||||||
|
isForked: repo.fork,
|
||||||
|
forkedFrom: undefined,
|
||||||
|
|
||||||
|
hasIssues: repo.has_issues,
|
||||||
|
isStarred: true,
|
||||||
|
isArchived: repo.archived,
|
||||||
|
|
||||||
|
size: repo.size,
|
||||||
|
hasLFS: false, // Placeholder
|
||||||
|
hasSubmodules: false, // Placeholder
|
||||||
|
|
||||||
|
defaultBranch: repo.default_branch,
|
||||||
|
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||||
|
|
||||||
|
status: "imported",
|
||||||
|
lastMirrored: undefined,
|
||||||
|
errorMessage: undefined,
|
||||||
|
|
||||||
|
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
|
||||||
|
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error fetching starred repositories: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user github organizations
|
||||||
|
*/
|
||||||
|
export async function getGithubOrganizations({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
config: Partial<Config>;
|
||||||
|
}): Promise<GitOrg[]> {
|
||||||
|
try {
|
||||||
|
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser({
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizations = await Promise.all(
|
||||||
|
orgs.map(async (org) => {
|
||||||
|
const [{ data: orgDetails }, { data: membership }] = await Promise.all([
|
||||||
|
octokit.orgs.get({ org: org.login }),
|
||||||
|
octokit.orgs.getMembershipForAuthenticatedUser({ org: org.login }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalRepos =
|
||||||
|
orgDetails.public_repos + (orgDetails.total_private_repos ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: org.login,
|
||||||
|
avatarUrl: org.avatar_url,
|
||||||
|
membershipRole: membership.role as MembershipRole,
|
||||||
|
isIncluded: false,
|
||||||
|
status: "imported" as RepoStatus,
|
||||||
|
repositoryCount: totalRepos,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return organizations;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error fetching organizations: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repositories for a specific organization
|
||||||
|
*/
|
||||||
|
export async function getGithubOrganizationRepositories({
|
||||||
|
octokit,
|
||||||
|
organizationName,
|
||||||
|
}: {
|
||||||
|
octokit: Octokit;
|
||||||
|
organizationName: string;
|
||||||
|
}): Promise<GitRepo[]> {
|
||||||
|
try {
|
||||||
|
const repos = await octokit.paginate(octokit.repos.listForOrg, {
|
||||||
|
org: organizationName,
|
||||||
|
per_page: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return repos.map((repo) => ({
|
||||||
|
name: repo.name,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
url: repo.html_url,
|
||||||
|
cloneUrl: repo.clone_url ?? "",
|
||||||
|
|
||||||
|
owner: repo.owner.login,
|
||||||
|
organization: repo.owner.login,
|
||||||
|
|
||||||
|
isPrivate: repo.private,
|
||||||
|
isForked: repo.fork,
|
||||||
|
forkedFrom: (repo as typeof repo & { parent?: { full_name: string } })
|
||||||
|
.parent?.full_name,
|
||||||
|
|
||||||
|
hasIssues: repo.has_issues ?? false,
|
||||||
|
isStarred: false, // Organization starred repos are separate API
|
||||||
|
isArchived: repo.archived ?? false,
|
||||||
|
|
||||||
|
size: repo.size ?? 0,
|
||||||
|
hasLFS: false,
|
||||||
|
hasSubmodules: false,
|
||||||
|
|
||||||
|
defaultBranch: repo.default_branch ?? "main",
|
||||||
|
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
|
||||||
|
|
||||||
|
status: "imported",
|
||||||
|
lastMirrored: undefined,
|
||||||
|
errorMessage: undefined,
|
||||||
|
|
||||||
|
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
|
||||||
|
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error fetching organization repositories: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/lib/helpers.ts
Normal file
53
src/lib/helpers.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { RepoStatus } from "@/types/Repository";
|
||||||
|
import { db, mirrorJobs } from "./db";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { redisPublisher } from "./redis";
|
||||||
|
|
||||||
|
export async function createMirrorJob({
|
||||||
|
userId,
|
||||||
|
organizationId,
|
||||||
|
organizationName,
|
||||||
|
repositoryId,
|
||||||
|
repositoryName,
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
organizationId?: string;
|
||||||
|
organizationName?: string;
|
||||||
|
repositoryId?: string;
|
||||||
|
repositoryName?: string;
|
||||||
|
details?: string;
|
||||||
|
message: string;
|
||||||
|
status: RepoStatus;
|
||||||
|
}) {
|
||||||
|
const jobId = uuidv4();
|
||||||
|
const currentTimestamp = new Date();
|
||||||
|
|
||||||
|
const job = {
|
||||||
|
id: jobId,
|
||||||
|
userId,
|
||||||
|
repositoryId,
|
||||||
|
repositoryName,
|
||||||
|
organizationId,
|
||||||
|
organizationName,
|
||||||
|
configId: uuidv4(),
|
||||||
|
details,
|
||||||
|
message: message,
|
||||||
|
status: status,
|
||||||
|
timestamp: currentTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(mirrorJobs).values(job);
|
||||||
|
|
||||||
|
const channel = `mirror-status:${userId}`;
|
||||||
|
await redisPublisher.publish(channel, JSON.stringify(job));
|
||||||
|
|
||||||
|
return jobId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating mirror job:", error);
|
||||||
|
throw new Error("Error creating mirror job");
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/lib/redis.ts
Normal file
4
src/lib/redis.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
export const redisPublisher = new Redis(); // For publishing
|
||||||
|
export const redisSubscriber = new Redis(); // For subscribing
|
||||||
160
src/lib/rough.ts
Normal file
160
src/lib/rough.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// this is a temporary file for testing purposes
|
||||||
|
import type { Config } from "@/types/config";
|
||||||
|
|
||||||
|
export async function deleteAllReposInOrg({
|
||||||
|
config,
|
||||||
|
org,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
org: string;
|
||||||
|
}) {
|
||||||
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Get all repositories in the organization
|
||||||
|
const repoRes = await fetch(
|
||||||
|
`${config.giteaConfig.url}/api/v1/orgs/${org}/repos`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!repoRes.ok) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch repos for org ${org}: ${await repoRes.text()}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = await repoRes.json();
|
||||||
|
|
||||||
|
// Step 2: Delete each repository
|
||||||
|
for (const repo of repos) {
|
||||||
|
const deleteRes = await fetch(
|
||||||
|
`${config.giteaConfig.url}/api/v1/repos/${org}/${repo.name}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleteRes.ok) {
|
||||||
|
console.error(
|
||||||
|
`Failed to delete repo ${repo.name}: ${await deleteRes.text()}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`Successfully deleted repo ${repo.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOrg({
|
||||||
|
config,
|
||||||
|
org,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
org: string;
|
||||||
|
}) {
|
||||||
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteOrgRes = await fetch(
|
||||||
|
`${config.giteaConfig.url}/api/v1/orgs/${org}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleteOrgRes.ok) {
|
||||||
|
console.error(`Failed to delete org ${org}: ${await deleteOrgRes.text()}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Successfully deleted org ${org}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllOrgs({
|
||||||
|
config,
|
||||||
|
orgs,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
orgs: string[];
|
||||||
|
}) {
|
||||||
|
for (const org of orgs) {
|
||||||
|
console.log(`Starting deletion for org: ${org}`);
|
||||||
|
|
||||||
|
// First, delete all repositories in the organization
|
||||||
|
await deleteAllReposInOrg({ config, org });
|
||||||
|
|
||||||
|
// Then, delete the organization itself
|
||||||
|
await deleteOrg({ config, org });
|
||||||
|
|
||||||
|
console.log(`Finished deletion for org: ${org}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllReposInGitea({
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
config: Partial<Config>;
|
||||||
|
}) {
|
||||||
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
||||||
|
throw new Error("Gitea config is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Fetching all repositories...");
|
||||||
|
|
||||||
|
// Step 1: Get all repositories (user + org repos)
|
||||||
|
const repoRes = await fetch(`${config.giteaConfig.url}/api/v1/user/repos`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig.token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!repoRes.ok) {
|
||||||
|
console.error(`Failed to fetch repositories: ${await repoRes.text()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repos = await repoRes.json();
|
||||||
|
|
||||||
|
if (repos.length === 0) {
|
||||||
|
console.log("No repositories found to delete.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${repos.length} repositories. Starting deletion...`);
|
||||||
|
|
||||||
|
// Step 2: Delete all repositories in parallel
|
||||||
|
await Promise.allSettled(
|
||||||
|
repos.map((repo: any) =>
|
||||||
|
fetch(
|
||||||
|
`${config.giteaConfig?.url}/api/v1/repos/${repo.owner.username}/${repo.name}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${config.giteaConfig?.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(
|
||||||
|
`Failed to delete repo ${repo.full_name}: ${await res.text()}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`Successfully deleted repo ${repo.full_name}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Finished deleting all repositories.");
|
||||||
|
}
|
||||||
98
src/lib/utils.ts
Normal file
98
src/lib/utils.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import axios from "axios";
|
||||||
|
import type { AxiosError, AxiosRequestConfig } from "axios";
|
||||||
|
import type { RepoStatus } from "@/types/Repository";
|
||||||
|
|
||||||
|
export const API_BASE = "/api";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date?: Date | string | null): string {
|
||||||
|
if (!date) return "Never";
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeParse<T>(value: unknown): T | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for API requests
|
||||||
|
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: AxiosRequestConfig = {}
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const response = await axios<T>(`${API_BASE}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as AxiosError<{ message?: string }>;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.message ||
|
||||||
|
"An unknown error occurred";
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStatusColor = (status: RepoStatus): string => {
|
||||||
|
switch (status) {
|
||||||
|
case "imported":
|
||||||
|
return "bg-blue-500"; // Info/primary-like
|
||||||
|
case "mirroring":
|
||||||
|
return "bg-yellow-400"; // In progress
|
||||||
|
case "mirrored":
|
||||||
|
return "bg-emerald-500"; // Success
|
||||||
|
case "failed":
|
||||||
|
return "bg-rose-500"; // Error
|
||||||
|
case "syncing":
|
||||||
|
return "bg-indigo-500"; // Sync in progress
|
||||||
|
case "synced":
|
||||||
|
return "bg-teal-500"; // Sync complete
|
||||||
|
default:
|
||||||
|
return "bg-gray-400"; // Unknown/neutral
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const jsonResponse = ({
|
||||||
|
data,
|
||||||
|
status = 200,
|
||||||
|
}: {
|
||||||
|
data: unknown;
|
||||||
|
status?: number;
|
||||||
|
}): Response => {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
65
src/pages/activity.astro
Normal file
65
src/pages/activity.astro
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
import '../styles/global.css';
|
||||||
|
import App from '@/components/layout/MainLayout';
|
||||||
|
import { db, mirrorJobs } from '@/lib/db';
|
||||||
|
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||||
|
|
||||||
|
// Fetch activity data from the database
|
||||||
|
let activityData = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch activity from mirror jobs
|
||||||
|
const jobs = await db.select().from(mirrorJobs).limit(20);
|
||||||
|
activityData = jobs.flatMap((job: any) => {
|
||||||
|
// Check if log exists before parsing
|
||||||
|
if (!job.log) {
|
||||||
|
console.warn(`Job ${job.id} has no log data`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const log = JSON.parse(job.log);
|
||||||
|
if (!Array.isArray(log)) {
|
||||||
|
console.warn(`Job ${job.id} log is not an array`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return log.map((entry: any) => ({
|
||||||
|
id: `${job.id}-${entry.timestamp}`,
|
||||||
|
message: entry.message,
|
||||||
|
timestamp: new Date(entry.timestamp),
|
||||||
|
status: entry.level,
|
||||||
|
details: entry.details,
|
||||||
|
repositoryName: entry.repositoryName,
|
||||||
|
}));
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(`Failed to parse log for job ${job.id}:`, parseError);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}).slice(0, 20);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching activity:', error);
|
||||||
|
// Fallback to empty array if database access fails
|
||||||
|
activityData = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side function to handle refresh
|
||||||
|
const handleRefresh = () => {
|
||||||
|
console.log('Refreshing activity log');
|
||||||
|
// In a real implementation, this would call the API to refresh the activity log
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="generator" content={Astro.generator} />
|
||||||
|
<title>Activity Log - Gitea Mirror</title>
|
||||||
|
<ThemeScript />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<App page='activity-log'client:load />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
src/pages/api/activities/index.ts
Normal file
58
src/pages/api/activities/index.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db, mirrorJobs, configs } from "@/lib/db";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import type { MirrorJob } from "@/lib/db/schema";
|
||||||
|
import { repoStatusEnum } from "@/types/Repository";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const searchParams = new URL(url).searchParams;
|
||||||
|
const userId = searchParams.get("userId");
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing 'userId' in query parameters." }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch mirror jobs associated with the user
|
||||||
|
const jobs = await db
|
||||||
|
.select()
|
||||||
|
.from(mirrorJobs)
|
||||||
|
.where(eq(mirrorJobs.userId, userId))
|
||||||
|
.orderBy(sql`${mirrorJobs.timestamp} DESC`);
|
||||||
|
|
||||||
|
const activities: MirrorJob[] = jobs.map((job) => ({
|
||||||
|
id: job.id,
|
||||||
|
userId: job.userId,
|
||||||
|
repositoryId: job.repositoryId ?? undefined,
|
||||||
|
repositoryName: job.repositoryName ?? undefined,
|
||||||
|
organizationId: job.organizationId ?? undefined,
|
||||||
|
organizationName: job.organizationName ?? undefined,
|
||||||
|
status: repoStatusEnum.parse(job.status),
|
||||||
|
details: job.details ?? undefined,
|
||||||
|
message: job.message,
|
||||||
|
timestamp: job.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Mirror job activities retrieved successfully.",
|
||||||
|
activities,
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching mirror job activities:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred.",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
83
src/pages/api/auth/index.ts
Normal file
83
src/pages/api/auth/index.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db, users, configs, client } from "@/lib/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request, cookies }) => {
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const userCountResult = await client.execute(
|
||||||
|
`SELECT COUNT(*) as count FROM users`
|
||||||
|
);
|
||||||
|
const userCount = userCountResult.rows[0].count;
|
||||||
|
|
||||||
|
if (userCount === 0) {
|
||||||
|
return new Response(JSON.stringify({ error: "No users found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||||
|
|
||||||
|
const userResult = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, decoded.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userResult.length) {
|
||||||
|
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, ...userWithoutPassword } = userResult[0];
|
||||||
|
|
||||||
|
const configResult = await db
|
||||||
|
.select({
|
||||||
|
scheduleConfig: configs.scheduleConfig,
|
||||||
|
})
|
||||||
|
.from(configs)
|
||||||
|
.where(and(eq(configs.userId, decoded.id), eq(configs.isActive, true)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const scheduleConfig = configResult[0]?.scheduleConfig;
|
||||||
|
|
||||||
|
const syncEnabled = scheduleConfig?.enabled ?? false;
|
||||||
|
const syncInterval = scheduleConfig?.interval ?? 3600;
|
||||||
|
const lastSync = scheduleConfig?.lastRun ?? null;
|
||||||
|
const nextSync = scheduleConfig?.nextRun ?? null;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
...userWithoutPassword,
|
||||||
|
syncEnabled,
|
||||||
|
syncInterval,
|
||||||
|
lastSync,
|
||||||
|
nextSync,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
62
src/pages/api/auth/login.ts
Normal file
62
src/pages/api/auth/login.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { db, users } from "@/lib/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Username and password are required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user.length) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid username or password" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user[0].password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid username or password" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password: _, ...userWithoutPassword } = user[0];
|
||||||
|
const token = jwt.sign({ id: user[0].id }, JWT_SECRET, { expiresIn: "7d" });
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
|
||||||
|
60 * 60 * 24 * 7
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
11
src/pages/api/auth/logout.ts
Normal file
11
src/pages/api/auth/logout.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async () => {
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": "token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
72
src/pages/api/auth/register.ts
Normal file
72
src/pages/api/auth/register.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { db, users } from "@/lib/db";
|
||||||
|
import { eq, or } from "drizzle-orm";
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
const { username, email, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !email || !password) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Username, email, and password are required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username or email already exists
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(or(eq(users.username, username), eq(users.email, email)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUser.length) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Username or email already exists" }),
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// Generate UUID
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const newUser = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const { password: _, ...userWithoutPassword } = newUser[0];
|
||||||
|
const token = jwt.sign({ id: newUser[0].id }, JWT_SECRET, {
|
||||||
|
expiresIn: "7d",
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
|
||||||
|
status: 201,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
|
||||||
|
60 * 60 * 24 * 7
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
225
src/pages/api/config/index.ts
Normal file
225
src/pages/api/config/index.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db, configs, users } from "@/lib/db";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { userId, githubConfig, giteaConfig, scheduleConfig } = body;
|
||||||
|
|
||||||
|
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"userId, githubConfig, giteaConfig, and scheduleConfig are required.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing config
|
||||||
|
const existingConfigResult = await db
|
||||||
|
.select()
|
||||||
|
.from(configs)
|
||||||
|
.where(eq(configs.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const existingConfig = existingConfigResult[0];
|
||||||
|
|
||||||
|
// Preserve tokens if fields are empty
|
||||||
|
if (existingConfig) {
|
||||||
|
try {
|
||||||
|
const existingGithub =
|
||||||
|
typeof existingConfig.githubConfig === "string"
|
||||||
|
? JSON.parse(existingConfig.githubConfig)
|
||||||
|
: existingConfig.githubConfig;
|
||||||
|
|
||||||
|
const existingGitea =
|
||||||
|
typeof existingConfig.giteaConfig === "string"
|
||||||
|
? JSON.parse(existingConfig.giteaConfig)
|
||||||
|
: existingConfig.giteaConfig;
|
||||||
|
|
||||||
|
if (!githubConfig.token && existingGithub.token) {
|
||||||
|
githubConfig.token = existingGithub.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!giteaConfig.token && existingGitea.token) {
|
||||||
|
giteaConfig.token = existingGitea.token;
|
||||||
|
}
|
||||||
|
} catch (tokenError) {
|
||||||
|
console.error("Failed to preserve tokens:", tokenError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingConfig) {
|
||||||
|
// Update path
|
||||||
|
await db
|
||||||
|
.update(configs)
|
||||||
|
.set({
|
||||||
|
githubConfig,
|
||||||
|
giteaConfig,
|
||||||
|
scheduleConfig,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(configs.id, existingConfig.id));
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Configuration updated successfully",
|
||||||
|
configId: existingConfig.id,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback user check (optional if you're always passing userId)
|
||||||
|
const userExists = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userExists.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: "Invalid userId. No matching user found.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new config
|
||||||
|
const configId = uuidv4();
|
||||||
|
await db.insert(configs).values({
|
||||||
|
id: configId,
|
||||||
|
userId,
|
||||||
|
name: "Default Configuration",
|
||||||
|
isActive: true,
|
||||||
|
githubConfig,
|
||||||
|
giteaConfig,
|
||||||
|
include: [],
|
||||||
|
exclude: [],
|
||||||
|
scheduleConfig,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: "Configuration created successfully",
|
||||||
|
configId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving configuration:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"Error saving configuration: " +
|
||||||
|
(error instanceof Error ? error.message : "Unknown error"),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return new Response(JSON.stringify({ error: "User ID is required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the configuration for the user
|
||||||
|
const config = await db
|
||||||
|
.select()
|
||||||
|
.from(configs)
|
||||||
|
.where(eq(configs.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (config.length === 0) {
|
||||||
|
// Return a default empty configuration instead of a 404 error
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: null,
|
||||||
|
userId: userId,
|
||||||
|
name: "Default Configuration",
|
||||||
|
isActive: true,
|
||||||
|
githubConfig: {
|
||||||
|
username: "",
|
||||||
|
token: "",
|
||||||
|
skipForks: false,
|
||||||
|
privateRepositories: false,
|
||||||
|
mirrorIssues: false,
|
||||||
|
mirrorStarred: true,
|
||||||
|
useSpecificUser: false,
|
||||||
|
preserveOrgStructure: true,
|
||||||
|
skipStarredIssues: false,
|
||||||
|
},
|
||||||
|
giteaConfig: {
|
||||||
|
url: "",
|
||||||
|
token: "",
|
||||||
|
username: "",
|
||||||
|
organization: "github-mirrors",
|
||||||
|
visibility: "public",
|
||||||
|
starredReposOrg: "github",
|
||||||
|
},
|
||||||
|
scheduleConfig: {
|
||||||
|
enabled: false,
|
||||||
|
interval: 3600,
|
||||||
|
lastRun: null,
|
||||||
|
nextRun: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(config[0]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching configuration:", error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : "Something went wrong",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
122
src/pages/api/dashboard/index.ts
Normal file
122
src/pages/api/dashboard/index.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
|
||||||
|
import { eq, count, and, sql, or } from "drizzle-orm";
|
||||||
|
import { jsonResponse } from "@/lib/utils";
|
||||||
|
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||||
|
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||||
|
import { membershipRoleEnum } from "@/types/organizations";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const userId = url.searchParams.get("userId");
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return jsonResponse({
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
error: "Missing userId",
|
||||||
|
},
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
userRepos,
|
||||||
|
userOrgs,
|
||||||
|
userLogs,
|
||||||
|
[userConfig],
|
||||||
|
[{ value: repoCount }],
|
||||||
|
[{ value: orgCount }],
|
||||||
|
[{ value: mirroredCount }],
|
||||||
|
] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(repositories)
|
||||||
|
.where(eq(repositories.userId, userId))
|
||||||
|
.orderBy(sql`${repositories.updatedAt} DESC`)
|
||||||
|
.limit(10),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.userId, userId))
|
||||||
|
.orderBy(sql`${organizations.updatedAt} DESC`)
|
||||||
|
.limit(10), // not really needed in the frontend but just in case
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(mirrorJobs)
|
||||||
|
.where(eq(mirrorJobs.userId, userId))
|
||||||
|
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
|
||||||
|
.limit(10),
|
||||||
|
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(repositories)
|
||||||
|
.where(eq(repositories.userId, userId)),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(organizations)
|
||||||
|
.where(eq(organizations.userId, userId)),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(repositories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(repositories.userId, userId),
|
||||||
|
or(
|
||||||
|
eq(repositories.status, "mirrored"),
|
||||||
|
eq(repositories.status, "synced")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const successResponse: DashboardApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: "Dashboard data loaded successfully",
|
||||||
|
repoCount: repoCount ?? 0,
|
||||||
|
orgCount: orgCount ?? 0,
|
||||||
|
mirroredCount: mirroredCount ?? 0,
|
||||||
|
repositories: userRepos.map((repo) => ({
|
||||||
|
...repo,
|
||||||
|
organization: repo.organization ?? undefined,
|
||||||
|
lastMirrored: repo.lastMirrored ?? undefined,
|
||||||
|
errorMessage: repo.errorMessage ?? undefined,
|
||||||
|
forkedFrom: repo.forkedFrom ?? undefined,
|
||||||
|
status: repoStatusEnum.parse(repo.status),
|
||||||
|
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
||||||
|
})),
|
||||||
|
organizations: userOrgs.map((org) => ({
|
||||||
|
...org,
|
||||||
|
status: repoStatusEnum.parse(org.status),
|
||||||
|
membershipRole: membershipRoleEnum.parse(org.membershipRole),
|
||||||
|
lastMirrored: org.lastMirrored ?? undefined,
|
||||||
|
errorMessage: org.errorMessage ?? undefined,
|
||||||
|
})),
|
||||||
|
activities: userLogs.map((job) => ({
|
||||||
|
id: job.id,
|
||||||
|
userId: job.userId,
|
||||||
|
repositoryName: job.repositoryName ?? undefined,
|
||||||
|
organizationName: job.organizationName ?? undefined,
|
||||||
|
status: repoStatusEnum.parse(job.status),
|
||||||
|
details: job.details ?? undefined,
|
||||||
|
message: job.message,
|
||||||
|
timestamp: job.timestamp,
|
||||||
|
})),
|
||||||
|
lastSync: userConfig?.scheduleConfig.lastRun ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonResponse({ data: successResponse });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading dashboard for user:", userId, error);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : "Internal server error",
|
||||||
|
message: "Failed to fetch dashboard data",
|
||||||
|
},
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
135
src/pages/api/gitea/test-connection.ts
Normal file
135
src/pages/api/gitea/test-connection.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { url, token, username } = body;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Gitea URL and token are required',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the URL (remove trailing slash if present)
|
||||||
|
const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
|
||||||
|
// Test the connection by fetching the authenticated user
|
||||||
|
const response = await axios.get(`${baseUrl}/api/v1/user`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `token ${token}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
// Verify that the authenticated user matches the provided username (if provided)
|
||||||
|
if (username && data.login !== username) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: `Token belongs to ${data.login}, not ${username}`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return success response with user data
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully connected to Gitea as ${data.login}`,
|
||||||
|
user: {
|
||||||
|
login: data.login,
|
||||||
|
name: data.full_name,
|
||||||
|
avatar_url: data.avatar_url,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gitea connection test failed:', error);
|
||||||
|
|
||||||
|
// Handle specific error types
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid Gitea token',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (error.response.status === 404) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Gitea API endpoint not found. Please check the URL.',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle connection errors
|
||||||
|
if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: 'Could not connect to Gitea server. Please check the URL.',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error response
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
message: `Gitea connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user