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