Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4958b8b9ec | ||
|
|
aedf0c23b8 | ||
|
|
b4937d1e01 | ||
|
|
498968695f | ||
|
|
d0e8e754a7 | ||
|
|
c95a501974 | ||
|
|
fd8f782f34 | ||
|
|
02ff865e4b | ||
|
|
df9da165c8 | ||
|
|
180f300752 | ||
|
|
472f67a6ae | ||
|
|
6270907e70 | ||
|
|
1deaae4d34 | ||
|
|
b984ff9af4 | ||
|
|
24bd0aefe6 | ||
|
|
6155e39360 |
BIN
.github/assets/activity.png
vendored
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 854 KiB |
BIN
.github/assets/activity_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
.github/assets/configuration.png
vendored
|
Before Width: | Height: | Size: 891 KiB After Width: | Height: | Size: 950 KiB |
BIN
.github/assets/configuration_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
.github/assets/dashboard_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
.github/assets/organisation.png
vendored
Normal file
|
After Width: | Height: | Size: 844 KiB |
BIN
.github/assets/organisation_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
.github/assets/organisations.png
vendored
|
Before Width: | Height: | Size: 784 KiB |
BIN
.github/assets/repositories_mobile.png
vendored
Normal file
|
After Width: | Height: | Size: 227 KiB |
21
CHANGELOG.md
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.22.0] - 2025-07-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Comprehensive mobile and responsive design support across the entire application
|
||||||
|
- New drawer UI component for enhanced mobile navigation
|
||||||
|
- Mobile-specific layouts for major components (ActivityLog, Header, Organization, Repository)
|
||||||
|
- Mobile screenshots in documentation showcasing responsive design
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Enhanced mobile user experience with optimized layouts for smaller screens
|
||||||
|
- Updated organization list cards with better mobile responsiveness
|
||||||
|
- Better touch interaction support throughout the application
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Type definition issues resolved
|
||||||
|
- Removed unnecessary console.log statements
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Updated README with mobile usage instructions and screenshots
|
||||||
|
- Added mobile-specific documentation sections
|
||||||
|
|
||||||
## [2.20.1] - 2025-07-07
|
## [2.20.1] - 2025-07-07
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
89
README.md
@@ -13,16 +13,17 @@
|
|||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using Docker (recommended)
|
# Fastest way - using the simplified Docker setup
|
||||||
docker compose up -d
|
docker compose -f docker-compose.alt.yml up -d
|
||||||
|
|
||||||
# Access at http://localhost:4321
|
# Access at http://localhost:4321
|
||||||
```
|
```
|
||||||
|
|
||||||
First user signup becomes admin. No configuration needed to get started!
|
First user signup becomes admin. Configure GitHub and Gitea through the web interface!
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src=".github/assets/dashboard.png" alt="Dashboard" width="full"/>
|
<img src=".github/assets/dashboard.png" alt="Dashboard" width="600" />
|
||||||
|
<img src=".github/assets/dashboard_mobile.png" alt="Dashboard Mobile" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -37,27 +38,71 @@ First user signup becomes admin. No configuration needed to get started!
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<div align="center">
|
||||||
<img src=".github/assets/repositories.png" width="49%"/>
|
<img src=".github/assets/repositories.png" alt="Repositories" width="600" />
|
||||||
<img src=".github/assets/organisations.png" width="49%"/>
|
<img src=".github/assets/repositories_mobile.png" alt="Rrepositories Mobile" width="200" />
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src=".github/assets/organisation.png" alt="Organisations" width="600" />
|
||||||
|
<img src=".github/assets/organisation_mobile.png" alt="Organisations Mobile" width="200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Docker (Recommended)
|
### Docker (Recommended)
|
||||||
|
|
||||||
|
We provide two Docker Compose options:
|
||||||
|
|
||||||
|
#### Option 1: Quick Start (docker-compose.alt.yml)
|
||||||
|
Perfect for trying out Gitea Mirror or simple deployments:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
git clone https://github.com/RayLabsHQ/gitea-mirror.git
|
||||||
cd gitea-mirror
|
cd gitea-mirror
|
||||||
|
|
||||||
# Start with Docker Compose
|
# Start with simplified setup
|
||||||
docker compose up -d
|
docker compose -f docker-compose.alt.yml up -d
|
||||||
|
|
||||||
# Access at http://localhost:4321
|
# Access at http://localhost:4321
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the pre-built image:
|
**Features:**
|
||||||
|
- ✅ Pre-built image - no building required
|
||||||
|
- ✅ Minimal configuration needed
|
||||||
|
- ✅ Data stored in `./data` directory
|
||||||
|
- ✅ Configure everything through web UI
|
||||||
|
- ✅ Automatic user/group ID mapping
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- First-time users
|
||||||
|
- Testing and evaluation
|
||||||
|
- Simple deployments
|
||||||
|
- When you prefer web-based configuration
|
||||||
|
|
||||||
|
#### Option 2: Full Setup (docker-compose.yml)
|
||||||
|
For production deployments with environment-based configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start with full configuration options
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- ✅ Build from source or use pre-built image
|
||||||
|
- ✅ Complete environment variable configuration
|
||||||
|
- ✅ Support for custom CA certificates
|
||||||
|
- ✅ Advanced mirror settings (forks, wiki, issues)
|
||||||
|
- ✅ Multi-registry support
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Production deployments
|
||||||
|
- Automated/scripted setups
|
||||||
|
- Advanced mirror configurations
|
||||||
|
- When using self-signed certificates
|
||||||
|
|
||||||
|
#### Using Pre-built Image Directly
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/raylabshq/gitea-mirror:v2.20.1
|
docker pull ghcr.io/raylabshq/gitea-mirror:v2.20.1
|
||||||
@@ -65,16 +110,28 @@ docker pull ghcr.io/raylabshq/gitea-mirror:v2.20.1
|
|||||||
|
|
||||||
### Configuration Options
|
### Configuration Options
|
||||||
|
|
||||||
Create a `.env` file for custom settings (optional):
|
#### Quick Start Configuration (docker-compose.alt.yml)
|
||||||
|
|
||||||
|
Minimal `.env` file (optional - has sensible defaults):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# JWT secret for authentication (auto-generated if blank or default below)
|
# Custom port (default: 4321)
|
||||||
JWT_SECRET=your-secret-key-change-this-in-production
|
|
||||||
|
|
||||||
# Port configuration
|
|
||||||
PORT=4321
|
PORT=4321
|
||||||
|
|
||||||
|
# User/Group IDs for file permissions (default: 1000)
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# JWT secret (auto-generated if not set)
|
||||||
|
JWT_SECRET=your-secret-key-change-this-in-production
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All other settings are configured through the web interface after starting.
|
||||||
|
|
||||||
|
#### Full Setup Configuration (docker-compose.yml)
|
||||||
|
|
||||||
|
Supports extensive environment variables for automated deployment. See the full [docker-compose.yml](docker-compose.yml) for all available options including GitHub tokens, Gitea URLs, mirror settings, and more.
|
||||||
|
|
||||||
### LXC Container (Proxmox)
|
### LXC Container (Proxmox)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
3
bun.lock
@@ -51,6 +51,7 @@
|
|||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.75",
|
"zod": "^3.25.75",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1453,6 +1454,8 @@
|
|||||||
|
|
||||||
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||||
|
|
||||||
|
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||||
|
|
||||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|
||||||
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gitea-mirror",
|
"name": "gitea-mirror",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.21.0",
|
"version": "2.22.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.2.9"
|
"bun": ">=1.2.9"
|
||||||
},
|
},
|
||||||
@@ -78,6 +78,7 @@
|
|||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.75"
|
"zod": "^3.25.75"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import type { MirrorJob } from '@/lib/db/schema';
|
import type { MirrorJob } from '@/lib/db/schema';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
|
||||||
import { Card } from '../ui/card';
|
import { Card } from '../ui/card';
|
||||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||||
import { Skeleton } from '../ui/skeleton';
|
import { Skeleton } from '../ui/skeleton';
|
||||||
import type { FilterParams } from '@/types/filter';
|
import type { FilterParams } from '@/types/filter';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '../ui/tooltip';
|
||||||
|
|
||||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||||
|
|
||||||
@@ -73,7 +79,7 @@ export default function ActivityList({
|
|||||||
count: filteredActivities.length,
|
count: filteredActivities.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
estimateSize: (idx) =>
|
estimateSize: (idx) =>
|
||||||
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120,
|
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||||
});
|
});
|
||||||
@@ -155,8 +161,8 @@ export default function ActivityList({
|
|||||||
}}
|
}}
|
||||||
className='border-b px-4 pt-4'
|
className='border-b px-4 pt-4'
|
||||||
>
|
>
|
||||||
<div className='flex items-start gap-4'>
|
<div className='flex items-start gap-3 sm:gap-4'>
|
||||||
<div className='relative mt-2'>
|
<div className='relative mt-2 flex-shrink-0'>
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
activity.status,
|
activity.status,
|
||||||
@@ -164,25 +170,112 @@ export default function ActivityList({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex-1'>
|
<div className='flex-1 min-w-0'>
|
||||||
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'>
|
<div className='mb-1 flex items-start justify-between gap-2'>
|
||||||
<p className='font-medium'>{activity.message}</p>
|
<div className='flex-1 min-w-0'>
|
||||||
<p className='text-sm text-muted-foreground'>
|
{/* Mobile: Show simplified status-based message */}
|
||||||
|
<div className='block sm:hidden'>
|
||||||
|
<p className='font-medium flex items-center gap-1.5'>
|
||||||
|
{activity.status === 'synced' ? (
|
||||||
|
<>
|
||||||
|
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400' />
|
||||||
|
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'mirrored' ? (
|
||||||
|
<>
|
||||||
|
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400' />
|
||||||
|
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'failed' ? (
|
||||||
|
<>
|
||||||
|
<X className='h-4 w-4 text-rose-600 dark:text-rose-400' />
|
||||||
|
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'syncing' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin' />
|
||||||
|
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'mirroring' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin' />
|
||||||
|
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'imported' ? (
|
||||||
|
<>
|
||||||
|
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400' />
|
||||||
|
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{activity.message}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Desktop: Show status with icon and full message in tooltip */}
|
||||||
|
<div className='hidden sm:block'>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<p className='font-medium flex items-center gap-1.5 cursor-help'>
|
||||||
|
{activity.status === 'synced' ? (
|
||||||
|
<>
|
||||||
|
<Check className='h-4 w-4 text-teal-600 dark:text-teal-400 flex-shrink-0' />
|
||||||
|
<span className='text-teal-600 dark:text-teal-400'>Sync successful</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'mirrored' ? (
|
||||||
|
<>
|
||||||
|
<Check className='h-4 w-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0' />
|
||||||
|
<span className='text-emerald-600 dark:text-emerald-400'>Mirror successful</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'failed' ? (
|
||||||
|
<>
|
||||||
|
<X className='h-4 w-4 text-rose-600 dark:text-rose-400 flex-shrink-0' />
|
||||||
|
<span className='text-rose-600 dark:text-rose-400'>Operation failed</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'syncing' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='h-4 w-4 text-indigo-600 dark:text-indigo-400 animate-spin flex-shrink-0' />
|
||||||
|
<span className='text-indigo-600 dark:text-indigo-400'>Syncing in progress</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'mirroring' ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='h-4 w-4 text-yellow-600 dark:text-yellow-400 animate-spin flex-shrink-0' />
|
||||||
|
<span className='text-yellow-600 dark:text-yellow-400'>Mirroring in progress</span>
|
||||||
|
</>
|
||||||
|
) : activity.status === 'imported' ? (
|
||||||
|
<>
|
||||||
|
<Import className='h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0' />
|
||||||
|
<span className='text-blue-600 dark:text-blue-400'>Imported</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className='truncate'>{activity.message}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start" className="max-w-[400px]">
|
||||||
|
<p className="whitespace-pre-wrap break-words">{activity.message}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-muted-foreground whitespace-nowrap flex-shrink-0 ml-2'>
|
||||||
{formatDate(activity.timestamp)}
|
{formatDate(activity.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
|
||||||
{activity.repositoryName && (
|
{activity.repositoryName && (
|
||||||
<p className='mb-2 text-sm text-muted-foreground'>
|
<p className='text-sm text-muted-foreground truncate'>
|
||||||
Repository: {activity.repositoryName}
|
<span className='font-medium'>Repo:</span> {activity.repositoryName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activity.organizationName && (
|
{activity.organizationName && (
|
||||||
<p className='mb-2 text-sm text-muted-foreground'>
|
<p className='text-sm text-muted-foreground truncate'>
|
||||||
Organization: {activity.organizationName}
|
<span className='font-medium'>Org:</span> {activity.organizationName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{activity.details && (
|
{activity.details && (
|
||||||
<div className='mt-2'>
|
<div className='mt-2'>
|
||||||
@@ -199,7 +292,7 @@ export default function ActivityList({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isExpanded ? 'Hide Details' : 'Show Details'}
|
{isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ChevronDown, Download, RefreshCw, Search, Trash2 } from 'lucide-react';
|
import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -36,6 +36,16 @@ import { toast } from 'sonner';
|
|||||||
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
|
||||||
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
import { useConfigStatus } from '@/hooks/useConfigStatus';
|
||||||
import { useNavigation } from '@/components/layout/MainLayout';
|
import { useNavigation } from '@/components/layout/MainLayout';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
|
||||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||||
|
|
||||||
@@ -343,18 +353,225 @@ export function ActivityLog() {
|
|||||||
setShowCleanupDialog(false);
|
setShowCleanupDialog(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
|
||||||
|
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilter({
|
||||||
|
searchTerm: filter.searchTerm,
|
||||||
|
status: '',
|
||||||
|
type: '',
|
||||||
|
name: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/* ------------------------------ UI ------------------------------ */
|
/* ------------------------------ UI ------------------------------ */
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-y-8'>
|
<div className='flex flex-col gap-y-4 sm:gap-y-8'>
|
||||||
<div className='flex w-full flex-row items-center gap-4'>
|
{/* Mobile: Search bar with filter and action buttons */}
|
||||||
{/* search input */}
|
<div className="flex flex-col gap-2 sm:hidden">
|
||||||
<div className='relative flex-1'>
|
<div className="flex items-center gap-2 w-full">
|
||||||
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' />
|
<div className="relative flex-grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type='text'
|
type="text"
|
||||||
placeholder='Search activities...'
|
placeholder="Search activities..."
|
||||||
className='h-9 w-full rounded-md border border-input bg-background px-3 py-1 pl-8 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'
|
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={filter.searchTerm}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter Drawer */}
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="relative h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[85vh]">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle className="text-lg font-semibold">Filter Activities</DrawerTitle>
|
||||||
|
<DrawerDescription className="text-sm text-muted-foreground">
|
||||||
|
Narrow down your activity log
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="px-4 py-6 space-y-6 overflow-y-auto">
|
||||||
|
{/* Active filters summary */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Status
|
||||||
|
{filter.status && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={filter.status || 'all'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFilter((p) => ({
|
||||||
|
...p,
|
||||||
|
status: v === 'all' ? '' : (v as RepoStatus),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-10">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{['all', ...repoStatusEnum.options].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{s !== 'all' && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
s === 'synced' ? 'bg-green-500' :
|
||||||
|
s === 'failed' ? 'bg-red-500' :
|
||||||
|
s === 'syncing' ? 'bg-blue-500' :
|
||||||
|
'bg-yellow-500'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Type
|
||||||
|
{filter.type && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{filter.type.charAt(0).toUpperCase() + filter.type.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={filter.type || 'all'}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-10">
|
||||||
|
<SelectValue placeholder="All types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{['all', 'repository', 'organization'].map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{t !== 'all' && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Name
|
||||||
|
{filter.name && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
Selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<ActivityNameCombobox
|
||||||
|
activities={activities}
|
||||||
|
value={filter.name || ''}
|
||||||
|
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button className="w-full" size="sm">
|
||||||
|
Apply Filters
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline" className="w-full" size="sm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => fetchActivities(false)}
|
||||||
|
title="Refresh activity log"
|
||||||
|
className="h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCleanupClick}
|
||||||
|
title="Delete all activities"
|
||||||
|
className="text-destructive hover:text-destructive h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Original layout */}
|
||||||
|
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||||
|
{/* search input */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search activities..."
|
||||||
|
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
value={filter.searchTerm}
|
value={filter.searchTerm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilter((prev) => ({
|
setFilter((prev) => ({
|
||||||
@@ -365,6 +582,8 @@ export function ActivityLog() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* status select */}
|
{/* status select */}
|
||||||
<Select
|
<Select
|
||||||
value={filter.status || 'all'}
|
value={filter.status || 'all'}
|
||||||
@@ -375,25 +594,28 @@ export function ActivityLog() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
<SelectTrigger className="w-[140px] h-10">
|
||||||
<SelectValue placeholder='All Status' />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{['all', ...repoStatusEnum.options].map((s) => (
|
{['all', ...repoStatusEnum.options].map((s) => (
|
||||||
<SelectItem key={s} value={s}>
|
<SelectItem key={s} value={s}>
|
||||||
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
|
<span className="flex items-center gap-2">
|
||||||
|
{s !== 'all' && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
s === 'synced' ? 'bg-green-500' :
|
||||||
|
s === 'failed' ? 'bg-red-500' :
|
||||||
|
s === 'syncing' ? 'bg-blue-500' :
|
||||||
|
'bg-yellow-500'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* repo/org name combobox */}
|
|
||||||
<ActivityNameCombobox
|
|
||||||
activities={activities}
|
|
||||||
value={filter.name || ''}
|
|
||||||
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* type select */}
|
{/* type select */}
|
||||||
<Select
|
<Select
|
||||||
value={filter.type || 'all'}
|
value={filter.type || 'all'}
|
||||||
@@ -401,28 +623,45 @@ export function ActivityLog() {
|
|||||||
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className='h-9 w-[140px] max-h-9'>
|
<SelectTrigger className="w-[140px] h-10">
|
||||||
<SelectValue placeholder='All Types' />
|
<SelectValue placeholder="All types" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{['all', 'repository', 'organization'].map((t) => (
|
{['all', 'repository', 'organization'].map((t) => (
|
||||||
<SelectItem key={t} value={t}>
|
<SelectItem key={t} value={t}>
|
||||||
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
|
<span className="flex items-center gap-2">
|
||||||
|
{t !== 'all' && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* repo/org name combobox */}
|
||||||
|
<ActivityNameCombobox
|
||||||
|
activities={activities}
|
||||||
|
value={filter.name || ''}
|
||||||
|
onChange={(name) => setFilter((p) => ({ ...p, name }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
{/* export dropdown */}
|
{/* export dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant='outline' className='flex items-center gap-1'>
|
<Button variant="outline" size="sm" className="h-10">
|
||||||
<Download className='mr-1 h-4 w-4' />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
<ChevronDown className='ml-1 h-4 w-4' />
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={exportAsCSV}>
|
<DropdownMenuItem onClick={exportAsCSV}>
|
||||||
Export as CSV
|
Export as CSV
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -436,10 +675,11 @@ export function ActivityLog() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
|
onClick={() => fetchActivities(false)}
|
||||||
title="Refresh activity log"
|
title="Refresh activity log"
|
||||||
|
className="h-10 w-10"
|
||||||
>
|
>
|
||||||
<RefreshCw className='h-4 w-4' />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* cleanup all activities */}
|
{/* cleanup all activities */}
|
||||||
@@ -448,11 +688,12 @@ export function ActivityLog() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleCleanupClick}
|
onClick={handleCleanupClick}
|
||||||
title="Delete all activities"
|
title="Delete all activities"
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive h-10 w-10"
|
||||||
>
|
>
|
||||||
<Trash2 className='h-4 w-4' />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* activity list */}
|
{/* activity list */}
|
||||||
<ActivityList
|
<ActivityList
|
||||||
@@ -486,6 +727,26 @@ export function ActivityLog() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Mobile FAB for Export - only visible on mobile */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 sm:hidden"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Download className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="top" className="mb-2">
|
||||||
|
<DropdownMenuItem onClick={exportAsCSV}>
|
||||||
|
Export as CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={exportAsJSON}>
|
||||||
|
Export as JSON
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="w-[180px] justify-between"
|
className="w-full sm:w-[180px] justify-between h-10"
|
||||||
>
|
>
|
||||||
{value ? value : "All Names"}
|
<span className={cn(
|
||||||
|
"truncate",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{value || "All names"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@@ -62,7 +67,7 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||||
All Names
|
All names
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{names.map((name) => (
|
{names.map((name) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ export function AutomationSettings({
|
|||||||
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
<CardTitle className="text-lg font-semibold flex items-center gap-2">
|
||||||
<Zap className="h-5 w-5" />
|
<Zap className="h-5 w-5" />
|
||||||
Automation & Maintenance
|
Automation & Maintenance
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button className="ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 bg-muted hover:bg-muted/80 transition-colors">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
<span className="sr-only">Background operations info</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="font-medium">Background Operations</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
|
||||||
|
Choose intervals that match your workflow and repository update frequency.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@@ -311,21 +330,6 @@ export function AutomationSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-blue-50/50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-900">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
|
||||||
Background Operations
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-blue-800 dark:text-blue-200/80">
|
|
||||||
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
|
|
||||||
Choose intervals that match your workflow and repository update frequency.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -563,17 +563,17 @@ export function ConfigTabs() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header section */}
|
{/* Header section */}
|
||||||
<div className="flex flex-row justify-between items-start">
|
<div className="flex flex-col md:flex-row justify-between gap-y-4 items-start">
|
||||||
<div className="flex flex-col gap-y-1.5">
|
<div className="flex flex-col gap-y-1.5">
|
||||||
<h1 className="text-2xl font-semibold leading-none tracking-tight">
|
<h1 className="text-2xl font-semibold leading-none tracking-tight">
|
||||||
Configuration Settings
|
Configuration
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Configure your GitHub and Gitea connections, and set up automatic
|
Configure your GitHub and Gitea connections, and set up automatic
|
||||||
mirroring.
|
mirroring.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4 w-full md:w-auto">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImportGitHubData}
|
onClick={handleImportGitHubData}
|
||||||
disabled={isSyncing || !isGitHubConfigValid()}
|
disabled={isSyncing || !isGitHubConfigValid()}
|
||||||
@@ -584,6 +584,7 @@ export function ConfigTabs() {
|
|||||||
? 'Import in progress'
|
? 'Import in progress'
|
||||||
: 'Import GitHub Data'
|
: 'Import GitHub Data'
|
||||||
}
|
}
|
||||||
|
className="w-full md:w-auto"
|
||||||
>
|
>
|
||||||
{isSyncing ? (
|
{isSyncing ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -88,15 +88,17 @@ export function GitHubConfigForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full h-full flex flex-col">
|
<Card className="w-full h-full flex flex-col">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-semibold">
|
||||||
GitHub Configuration
|
GitHub Configuration
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{/* Desktop: Show button in header */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="default"
|
||||||
onClick={testConnection}
|
onClick={testConnection}
|
||||||
disabled={isLoading || !config.token}
|
disabled={isLoading || !config.token}
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
>
|
>
|
||||||
{isLoading ? "Testing..." : "Test Connection"}
|
{isLoading ? "Testing..." : "Test Connection"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -200,6 +202,17 @@ export function GitHubConfigForm({
|
|||||||
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
|
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mobile: Show button at bottom */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={isLoading || !config.token}
|
||||||
|
className="sm:hidden w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? "Testing..." : "Test Connection"}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function GitHubMirrorSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="starred-repos"
|
id="starred-repos"
|
||||||
@@ -145,10 +145,10 @@ export function GitHubMirrorSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Starred repos content selection - inline to prevent layout shift */}
|
{/* Starred repos content selection - responsive layout */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex items-center justify-end transition-opacity duration-200",
|
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
|
||||||
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
|
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 hidden pointer-events-none"
|
||||||
)}>
|
)}>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -156,7 +156,7 @@ export function GitHubMirrorSettings({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!githubConfig.mirrorStarred}
|
disabled={!githubConfig.mirrorStarred}
|
||||||
className="h-8 text-xs font-normal min-w-[140px] justify-between"
|
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{advancedOptions.skipStarredIssues ? (
|
{advancedOptions.skipStarredIssues ? (
|
||||||
@@ -325,7 +325,7 @@ export function GitHubMirrorSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="mirror-metadata"
|
id="mirror-metadata"
|
||||||
@@ -346,10 +346,10 @@ export function GitHubMirrorSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata multi-select - inline to prevent layout shift */}
|
{/* Metadata multi-select - responsive layout */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"flex items-center justify-end transition-opacity duration-200",
|
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
|
||||||
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 pointer-events-none"
|
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 hidden pointer-events-none"
|
||||||
)}>
|
)}>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -357,7 +357,7 @@ export function GitHubMirrorSettings({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!mirrorOptions.mirrorMetadata}
|
disabled={!mirrorOptions.mirrorMetadata}
|
||||||
className="h-8 text-xs font-normal min-w-[140px] justify-between"
|
className="h-8 text-xs font-normal min-w-[140px] md:min-w-[140px] justify-between"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -136,15 +136,17 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full h-full flex flex-col">
|
<Card className="w-full h-full flex flex-col">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-semibold">
|
||||||
Gitea Configuration
|
Gitea Configuration
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{/* Desktop: Show button in header */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="default"
|
||||||
onClick={testConnection}
|
onClick={testConnection}
|
||||||
disabled={isLoading || !config.url || !config.token}
|
disabled={isLoading || !config.url || !config.token}
|
||||||
|
className="hidden sm:inline-flex"
|
||||||
>
|
>
|
||||||
{isLoading ? "Testing..." : "Test Connection"}
|
{isLoading ? "Testing..." : "Test Connection"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -252,6 +254,17 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
|||||||
if (onAutoSave) onAutoSave(newConfig);
|
if (onAutoSave) onAutoSave(newConfig);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mobile: Show button at bottom */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={isLoading || !config.url || !config.token}
|
||||||
|
className="sm:hidden w-full"
|
||||||
|
>
|
||||||
|
{isLoading ? "Testing..." : "Test Connection"}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
|
|||||||
<Icon className="h-3.5 w-3.5" />
|
<Icon className="h-3.5 w-3.5" />
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity" />
|
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity hidden sm:inline-block" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const strategyConfig = {
|
|||||||
preserve: {
|
preserve: {
|
||||||
title: "Preserve Structure",
|
title: "Preserve Structure",
|
||||||
icon: FolderTree,
|
icon: FolderTree,
|
||||||
description: "Keep the exact same organization structure as GitHub",
|
description: "Keep the exact same org structure as GitHub",
|
||||||
color: "text-blue-600 dark:text-blue-400",
|
color: "text-blue-600 dark:text-blue-400",
|
||||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||||
borderColor: "border-blue-200 dark:border-blue-900",
|
borderColor: "border-blue-200 dark:border-blue-900",
|
||||||
@@ -60,7 +60,7 @@ const strategyConfig = {
|
|||||||
"mixed": {
|
"mixed": {
|
||||||
title: "Mixed Mode",
|
title: "Mixed Mode",
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
description: "user repos in single org, org repos preserve structure",
|
description: "Personal repos in single org, org repos preserve structure",
|
||||||
color: "text-orange-600 dark:text-orange-400",
|
color: "text-orange-600 dark:text-orange-400",
|
||||||
bgColor: "bg-orange-50 dark:bg-orange-950/20",
|
bgColor: "bg-orange-50 dark:bg-orange-950/20",
|
||||||
borderColor: "border-orange-200 dark:border-orange-900",
|
borderColor: "border-orange-200 dark:border-orange-900",
|
||||||
@@ -281,7 +281,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||||
<Building className="h-4 w-4" />
|
<Building className="h-4 w-4" />
|
||||||
@@ -303,7 +303,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
|||||||
<span>Override Options</span>
|
<span>Override Options</span>
|
||||||
</button>
|
</button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent side="left" align="start" className="w-[380px]">
|
<HoverCardContent side="bottom" align="start" className="w-[380px]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4>
|
<h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4>
|
||||||
@@ -371,15 +371,16 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
|||||||
!isSelected && "border-muted"
|
!isSelected && "border-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-3">
|
<div className="p-3 sm:p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
value={key}
|
value={key}
|
||||||
id={key}
|
id={key}
|
||||||
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"rounded-lg p-2",
|
"rounded-lg p-2 flex-shrink-0",
|
||||||
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
|
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
|
||||||
)}>
|
)}>
|
||||||
<Icon className={cn(
|
<Icon className={cn(
|
||||||
@@ -388,11 +389,11 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
|||||||
)} />
|
)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="font-medium text-sm">{config.title}</h4>
|
<h4 className="font-medium text-sm">{config.title}</h4>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{config.description}
|
{config.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -400,10 +401,10 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
|||||||
<HoverCard openDelay={200}>
|
<HoverCard openDelay={200}>
|
||||||
<HoverCardTrigger asChild>
|
<HoverCardTrigger asChild>
|
||||||
<span
|
<span
|
||||||
className="inline-flex p-1.5 hover:bg-muted rounded-md transition-colors cursor-help"
|
className="inline-flex p-1 sm:p-1.5 hover:bg-muted rounded-md transition-colors cursor-help flex-shrink-0 ml-2"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Info className="h-4 w-4 text-muted-foreground" />
|
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
|
||||||
</span>
|
</span>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
<HoverCardContent side="left" align="center" className="w-[500px]">
|
<HoverCardContent side="left" align="center" className="w-[500px]">
|
||||||
@@ -422,6 +423,8 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setActivities((prevActivities) => [data, ...prevActivities]);
|
setActivities((prevActivities) => [data, ...prevActivities]);
|
||||||
|
|
||||||
console.log("Received new log:", data);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Use the SSE hook
|
// Use the SSE hook
|
||||||
@@ -179,16 +177,16 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return isLoading || !connected ? (
|
return isLoading || !connected ? (
|
||||||
<div className="flex flex-col gap-y-6">
|
<div className="flex flex-col gap-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-x-6 items-start">
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||||
{/* Repository List Skeleton */}
|
{/* Repository List Skeleton */}
|
||||||
<div className="w-1/2 border rounded-lg p-4">
|
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24" />
|
||||||
@@ -201,7 +199,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity Skeleton */}
|
{/* Recent Activity Skeleton */}
|
||||||
<div className="w-1/2 border rounded-lg p-4">
|
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24" />
|
||||||
@@ -217,24 +215,24 @@ export function Dashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-y-6">
|
<div className="flex flex-col gap-y-6">
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Total Repositories"
|
title="Repositories"
|
||||||
value={repoCount}
|
value={repoCount}
|
||||||
icon={<GitFork className="h-4 w-4" />}
|
icon={<GitFork className="h-4 w-4" />}
|
||||||
description="Repositories being mirrored"
|
description="Total in mirror queue"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Mirrored"
|
title="Mirrored"
|
||||||
value={mirroredCount}
|
value={mirroredCount}
|
||||||
icon={<FlipHorizontal className="h-4 w-4" />}
|
icon={<FlipHorizontal className="h-4 w-4" />}
|
||||||
description="Successfully mirrored"
|
description="Synced to Gitea"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Organizations"
|
title="Organizations"
|
||||||
value={orgCount}
|
value={orgCount}
|
||||||
icon={<Building2 className="h-4 w-4" />}
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
description="GitHub organizations"
|
description="From GitHub"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Last Sync"
|
title="Last Sync"
|
||||||
@@ -254,12 +252,16 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-x-6 items-start">
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||||
|
<div className="w-full lg:w-1/2">
|
||||||
<RepositoryList repositories={repositories} />
|
<RepositoryList repositories={repositories} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-1/2">
|
||||||
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
||||||
<RecentActivity activities={activities.slice(0, 10)} />
|
<RecentActivity activities={activities.slice(0, 10)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
|||||||
<a href="/activity">View All</a>
|
<a href="/activity">View All</a>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||||
<div className="flex flex-col divide-y divide-border">
|
<div className="flex flex-col divide-y divide-border">
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No recent activity</p>
|
<p className="text-sm text-muted-foreground">No recent activity</p>
|
||||||
@@ -31,7 +31,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">
|
<p className="text-sm font-medium leading-none break-words">
|
||||||
{activity.message}
|
{activity.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
<a href="/repositories">View All</a>
|
<a href="/repositories">View All</a>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||||
{repositories.length === 0 ? (
|
{repositories.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
@@ -71,11 +71,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
{repositories.map((repo, index) => (
|
{repositories.map((repo, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center justify-between gap-x-4 py-4"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-x-4 py-4"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
<h4 className="text-sm font-medium">{repo.name}</h4>
|
<h4 className="text-sm font-medium break-all">{repo.name}</h4>
|
||||||
{repo.isPrivate && (
|
{repo.isPrivate && (
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
Private
|
Private
|
||||||
@@ -99,13 +99,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 sm:ml-auto">
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
repo.status
|
repo.status
|
||||||
)}`}
|
)}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs capitalize w-[3rem]">
|
<span className="text-xs capitalize w-[3rem] sm:w-auto">
|
||||||
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
||||||
{repo.status}
|
{repo.status}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,13 +7,21 @@ import { toast } from "sonner";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||||
|
import { Menu, LogOut } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||||
onNavigate?: (page: string) => void;
|
onNavigate?: (page: string) => void;
|
||||||
|
onMenuClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ currentPage, onNavigate }: HeaderProps) {
|
export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||||
const { user, logout, isLoading } = useAuth();
|
const { user, logout, isLoading } = useAuth();
|
||||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||||
@@ -54,7 +62,19 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b bg-background">
|
<header className="border-b bg-background">
|
||||||
<div className="flex h-[4.5rem] items-center justify-between px-6">
|
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Hamburger Menu Button - Mobile Only */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle menu</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentPage !== 'dashboard') {
|
if (currentPage !== 'dashboard') {
|
||||||
@@ -74,19 +94,20 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
alt="Gitea Mirror Logo"
|
alt="Gitea Mirror Logo"
|
||||||
className="h-6 w-6 hidden dark:block"
|
className="h-6 w-6 hidden dark:block"
|
||||||
/>
|
/>
|
||||||
<span className="text-xl font-bold">Gitea Mirror</span>
|
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
{showLiveButton && (
|
{showLiveButton && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-1.5 px-3 sm:px-4"
|
||||||
onClick={toggleLive}
|
onClick={toggleLive}
|
||||||
title={getTooltip()}
|
title={getTooltip()}
|
||||||
>
|
>
|
||||||
<div className={`w-3 h-3 rounded-full ${
|
<div className={`size-4 sm:size-3 rounded-full ${
|
||||||
configLoading
|
configLoading
|
||||||
? 'bg-yellow-400 animate-pulse'
|
? 'bg-yellow-400 animate-pulse'
|
||||||
: isLiveActive
|
: isLiveActive
|
||||||
@@ -95,7 +116,7 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
? 'bg-orange-400'
|
? 'bg-orange-400'
|
||||||
: 'bg-gray-500'
|
: 'bg-gray-500'
|
||||||
}`} />
|
}`} />
|
||||||
<span>LIVE</span>
|
<span className="text-sm font-medium hidden sm:inline">LIVE</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -104,19 +125,26 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<AuthButtonsSkeleton />
|
<AuthButtonsSkeleton />
|
||||||
) : user ? (
|
) : user ? (
|
||||||
<>
|
<DropdownMenu>
|
||||||
<Avatar>
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
|
||||||
|
<Avatar className="h-full w-full">
|
||||||
<AvatarImage src="" alt="@shadcn" />
|
<AvatarImage src="" alt="@shadcn" />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Button variant="outline" size="lg" onClick={handleLogout}>
|
|
||||||
Logout
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="lg" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<a href="/login">Login</a>
|
<a href="/login">Login</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
|||||||
const { isLoading: configLoading } = useConfigStatus();
|
const { isLoading: configLoading } = useConfigStatus();
|
||||||
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
||||||
const [navigationKey, setNavigationKey] = useState(0);
|
const [navigationKey, setNavigationKey] = useState(0);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
useRepoSync({
|
useRepoSync({
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@@ -99,10 +100,18 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
|||||||
return (
|
return (
|
||||||
<NavigationContext.Provider value={{ navigationKey }}>
|
<NavigationContext.Provider value={{ navigationKey }}>
|
||||||
<main className="flex min-h-screen flex-col">
|
<main className="flex min-h-screen flex-col">
|
||||||
<Header currentPage={currentPage} onNavigate={handleNavigation} />
|
<Header
|
||||||
<div className="flex flex-1">
|
currentPage={currentPage}
|
||||||
<Sidebar onNavigate={handleNavigation} />
|
onNavigate={handleNavigation}
|
||||||
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
|
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 relative">
|
||||||
|
<Sidebar
|
||||||
|
onNavigate={handleNavigation}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<section className="flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full lg:w-[calc(100%-16rem)]">
|
||||||
{currentPage === "dashboard" && <Dashboard />}
|
{currentPage === "dashboard" && <Dashboard />}
|
||||||
{currentPage === "repositories" && <Repository />}
|
{currentPage === "repositories" && <Repository />}
|
||||||
{currentPage === "organizations" && <Organization />}
|
{currentPage === "organizations" && <Organization />}
|
||||||
|
|||||||
@@ -7,16 +7,17 @@ import { VersionInfo } from "./VersionInfo";
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
onNavigate?: (page: string) => void;
|
onNavigate?: (page: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ className, onNavigate }: SidebarProps) {
|
export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
|
||||||
const [currentPath, setCurrentPath] = useState<string>("");
|
const [currentPath, setCurrentPath] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hydration happens here
|
// Hydration happens here
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
setCurrentPath(path);
|
setCurrentPath(path);
|
||||||
console.log("Hydrated path:", path); // Should log now
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Listen for URL changes (browser back/forward)
|
// Listen for URL changes (browser back/forward)
|
||||||
@@ -50,12 +51,33 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
|
|||||||
|
|
||||||
const pageName = pageMap[href] || 'dashboard';
|
const pageName = pageMap[href] || 'dashboard';
|
||||||
onNavigate?.(pageName);
|
onNavigate?.(pageName);
|
||||||
|
|
||||||
|
// Close sidebar on mobile after navigation
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn("w-64 border-r bg-background", className)}>
|
<>
|
||||||
<div className="flex flex-col h-full pt-4">
|
{/* Mobile Backdrop */}
|
||||||
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 backdrop-blur-sm z-40 lg:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-background border-r flex flex-col h-full lg:h-[calc(100vh-4.5rem)] transition-transform duration-200 ease-in-out lg:translate-x-0",
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<nav className="flex flex-col gap-y-1 lg:gap-y-1 pl-2 pr-3 pt-4 flex-shrink-0">
|
||||||
{links.map((link, index) => {
|
{links.map((link, index) => {
|
||||||
const isActive = currentPath === link.href;
|
const isActive = currentPath === link.href;
|
||||||
const Icon = link.icon;
|
const Icon = link.icon;
|
||||||
@@ -65,38 +87,41 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
|
|||||||
key={index}
|
key={index}
|
||||||
onClick={(e) => handleNavigation(link.href, e)}
|
onClick={(e) => handleNavigation(link.href, e)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left",
|
"flex items-center gap-3 rounded-md px-3 py-3 lg:py-2 text-sm lg:text-sm font-medium transition-colors w-full text-left",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-5 w-5 lg:h-4 lg:w-4" />
|
||||||
{link.label}
|
{link.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto px-4 py-4">
|
<div className="flex-1 min-h-0" />
|
||||||
<div className="rounded-md bg-muted p-3">
|
|
||||||
|
<div className="px-4 py-4 flex-shrink-0">
|
||||||
|
<div className="rounded-md bg-muted p-3 lg:p-3">
|
||||||
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
<p className="text-xs text-muted-foreground mb-3 lg:mb-2">
|
||||||
Check out the documentation for help with setup and configuration.
|
Check out the documentation for help with setup and configuration.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/docs"
|
href="/docs"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
|
||||||
>
|
>
|
||||||
Documentation
|
Documentation
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<VersionInfo />
|
<VersionInfo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,12 +63,12 @@ export default function AddOrganizationDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
|
<Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
|
||||||
<Plus className="h-6 w-6" />
|
<Plus className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
|
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Organization</DialogTitle>
|
<DialogTitle>Add Organization</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -69,19 +69,19 @@ export function MirrorDestinationEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-2", className)}>
|
<div className={cn("flex items-center gap-2 w-full", className)}>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
|
||||||
<Building2 className="h-3 w-3" />
|
<Building2 className="h-3 w-3 flex-shrink-0" />
|
||||||
<span className="font-medium">{organizationName}</span>
|
<span className="font-medium truncate">{organizationName}</span>
|
||||||
<ArrowRight className="h-3 w-3" />
|
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"font-medium",
|
"font-medium truncate",
|
||||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||||
)}>
|
)}>
|
||||||
{effectiveDestination}
|
{effectiveDestination}
|
||||||
</span>
|
</span>
|
||||||
{hasOverride && (
|
{hasOverride && (
|
||||||
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
|
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400 flex-shrink-0">
|
||||||
custom
|
custom
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -92,11 +92,11 @@ export function MirrorDestinationEditor({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
|
className="h-10 w-10 sm:h-6 sm:w-6 p-0 opacity-60 hover:opacity-100"
|
||||||
title="Edit mirror destination"
|
title="Edit mirror destination"
|
||||||
disabled={isUpdating || isLoading}
|
disabled={isUpdating || isLoading}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-3 w-3" />
|
<Edit3 className="h-5 w-5 sm:h-3 sm:w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80" align="end">
|
<PopoverContent className="w-80" align="end">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
|
import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react";
|
||||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||||
import { OrganizationList } from "./OrganizationsList";
|
import { OrganizationList } from "./OrganizationsList";
|
||||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||||
@@ -27,6 +27,16 @@ import { toast } from "sonner";
|
|||||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||||
import { useNavigation } from "@/components/layout/MainLayout";
|
import { useNavigation } from "@/components/layout/MainLayout";
|
||||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
export function Organization() {
|
export function Organization() {
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
@@ -54,8 +64,6 @@ export function Organization() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Received new log:", data);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Use the SSE hook
|
// Use the SSE hook
|
||||||
@@ -290,18 +298,31 @@ export function Organization() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasActiveFilters = !!(filter.membershipRole || filter.status);
|
||||||
|
const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length;
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilter({
|
||||||
|
searchTerm: filter.searchTerm,
|
||||||
|
membershipRole: "",
|
||||||
|
status: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-8">
|
<div className="flex flex-col gap-y-4 sm:gap-y-8">
|
||||||
{/* Combine search and actions into a single flex row */}
|
{/* Search and filters */}
|
||||||
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
|
||||||
|
{/* Mobile: Search bar with filter button */}
|
||||||
|
<div className="flex items-center gap-2 w-full sm:hidden">
|
||||||
<div className="relative flex-grow">
|
<div className="relative flex-grow">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search Organizations..."
|
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"
|
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
value={filter.searchTerm}
|
value={filter.searchTerm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
@@ -309,6 +330,210 @@ export function Organization() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter Drawer */}
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="relative h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[85vh]">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle className="text-lg font-semibold">Filter Organizations</DrawerTitle>
|
||||||
|
<DrawerDescription className="text-sm text-muted-foreground">
|
||||||
|
Narrow down your organization list
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="px-4 py-6 space-y-6 overflow-y-auto">
|
||||||
|
{/* Active filters summary */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Role
|
||||||
|
{filter.membershipRole && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{filter.membershipRole
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={filter.membershipRole || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
membershipRole: value === "all" ? "" : (value as MembershipRole),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-10">
|
||||||
|
<SelectValue placeholder="All roles" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["all", ...membershipRoleEnum.options].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{role !== "all" && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
role === "admin" ? "bg-purple-500" : "bg-blue-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{role === "all"
|
||||||
|
? "All roles"
|
||||||
|
: role
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Status
|
||||||
|
{filter.status && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={filter.status || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status:
|
||||||
|
value === "all"
|
||||||
|
? ""
|
||||||
|
: (value as
|
||||||
|
| ""
|
||||||
|
| "imported"
|
||||||
|
| "mirroring"
|
||||||
|
| "mirrored"
|
||||||
|
| "failed"
|
||||||
|
| "syncing"
|
||||||
|
| "synced"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-10">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[
|
||||||
|
"all",
|
||||||
|
"imported",
|
||||||
|
"mirroring",
|
||||||
|
"mirrored",
|
||||||
|
"failed",
|
||||||
|
"syncing",
|
||||||
|
"synced",
|
||||||
|
].map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{status !== "all" && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
status === "synced" || status === "mirrored" ? "bg-green-500" :
|
||||||
|
status === "failed" ? "bg-red-500" :
|
||||||
|
status === "syncing" || status === "mirroring" ? "bg-blue-500" :
|
||||||
|
"bg-yellow-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{status === "all"
|
||||||
|
? "All statuses"
|
||||||
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button className="w-full" size="sm">
|
||||||
|
Apply Filters
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline" className="w-full" size="sm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
title="Refresh organizations"
|
||||||
|
className="h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleMirrorAllOrgs}
|
||||||
|
disabled={isLoading || loadingOrgIds.size > 0}
|
||||||
|
title="Mirror all organizations"
|
||||||
|
className="h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Original layout */}
|
||||||
|
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search organizations..."
|
||||||
|
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={filter.searchTerm}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* Membership Role Filter */}
|
{/* Membership Role Filter */}
|
||||||
<Select
|
<Select
|
||||||
value={filter.membershipRole || "all"}
|
value={filter.membershipRole || "all"}
|
||||||
@@ -319,17 +544,24 @@ export function Organization() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
<SelectTrigger className="w-[140px] h-10">
|
||||||
<SelectValue placeholder="All Roles" />
|
<SelectValue placeholder="All roles" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{["all", ...membershipRoleEnum.options].map((role) => (
|
{["all", ...membershipRoleEnum.options].map((role) => (
|
||||||
<SelectItem key={role} value={role}>
|
<SelectItem key={role} value={role}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{role !== "all" && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
role === "admin" ? "bg-purple-500" : "bg-blue-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
{role === "all"
|
{role === "all"
|
||||||
? "All Roles"
|
? "All roles"
|
||||||
: role
|
: role
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -355,8 +587,8 @@ export function Organization() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
<SelectTrigger className="w-[140px] h-10">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[
|
{[
|
||||||
@@ -369,19 +601,33 @@ export function Organization() {
|
|||||||
"synced",
|
"synced",
|
||||||
].map((status) => (
|
].map((status) => (
|
||||||
<SelectItem key={status} value={status}>
|
<SelectItem key={status} value={status}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{status !== "all" && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
status === "synced" || status === "mirrored" ? "bg-green-500" :
|
||||||
|
status === "failed" ? "bg-red-500" :
|
||||||
|
status === "syncing" || status === "mirroring" ? "bg-blue-500" :
|
||||||
|
"bg-yellow-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
{status === "all"
|
{status === "all"
|
||||||
? "All Statuses"
|
? "All statuses"
|
||||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
title="Refresh organizations"
|
title="Refresh organizations"
|
||||||
|
className="h-10 w-10"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -390,11 +636,14 @@ export function Organization() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleMirrorAllOrgs}
|
onClick={handleMirrorAllOrgs}
|
||||||
disabled={isLoading || loadingOrgIds.size > 0}
|
disabled={isLoading || loadingOrgIds.size > 0}
|
||||||
|
className="h-10 px-4"
|
||||||
>
|
>
|
||||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
Mirror All
|
Mirror All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<OrganizationList
|
<OrganizationList
|
||||||
organizations={organizations}
|
organizations={organizations}
|
||||||
@@ -404,7 +653,9 @@ export function Organization() {
|
|||||||
loadingOrgIds={loadingOrgIds}
|
loadingOrgIds={loadingOrgIds}
|
||||||
onMirror={handleMirrorOrg}
|
onMirror={handleMirrorOrg}
|
||||||
onAddOrganization={() => setIsDialogOpen(true)}
|
onAddOrganization={() => setIsDialogOpen(true)}
|
||||||
onRefresh={() => fetchOrganizations(false)}
|
onRefresh={async () => {
|
||||||
|
await fetchOrganizations(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddOrganizationDialog
|
<AddOrganizationDialog
|
||||||
|
|||||||
@@ -127,9 +127,9 @@ export function OrganizationList({
|
|||||||
}, [organizations, filter]);
|
}, [organizations, filter]);
|
||||||
|
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(27rem,1fr))] gap-4">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-[136px] w-full" />
|
<Skeleton key={i} className="h-[11.25rem] w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredOrganizations.length === 0 ? (
|
) : filteredOrganizations.length === 0 ? (
|
||||||
@@ -161,7 +161,7 @@ export function OrganizationList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fit,minmax(27rem,1fr))] gap-4 pb-20 sm:pb-0">
|
||||||
{filteredOrganizations.map((org, index) => {
|
{filteredOrganizations.map((org, index) => {
|
||||||
const isLoading = loadingOrgIds.has(org.id ?? "");
|
const isLoading = loadingOrgIds.has(org.id ?? "");
|
||||||
const statusBadge = getStatusBadge(org.status);
|
const statusBadge = getStatusBadge(org.status);
|
||||||
@@ -171,20 +171,33 @@ export function OrganizationList({
|
|||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
|
"overflow-hidden p-4 sm:p-6 transition-all hover:shadow-lg hover:border-foreground/10 w-full",
|
||||||
isLoading && "opacity-75"
|
isLoading && "opacity-75"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
{/* Mobile Layout */}
|
||||||
<div className="flex-1">
|
<div className="flex flex-col gap-3 sm:hidden">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
{/* Header with org name and badges */}
|
||||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
<a
|
<a
|
||||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||||
className="font-medium hover:underline cursor-pointer"
|
className="font-medium hover:underline cursor-pointer truncate"
|
||||||
>
|
>
|
||||||
{org.name}
|
{org.name}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusBadge.variant} className="flex-shrink-0">
|
||||||
|
{StatusIcon && <StatusIcon className={cn(
|
||||||
|
"h-3 w-3",
|
||||||
|
org.status === "mirroring" && "animate-pulse"
|
||||||
|
)} />}
|
||||||
|
{statusBadge.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
||||||
org.membershipRole === "member"
|
org.membershipRole === "member"
|
||||||
@@ -195,9 +208,10 @@ export function OrganizationList({
|
|||||||
{org.membershipRole}
|
{org.membershipRole}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Destination override section */}
|
{/* Destination override section */}
|
||||||
<div className="mt-2">
|
<div>
|
||||||
<MirrorDestinationEditor
|
<MirrorDestinationEditor
|
||||||
organizationId={org.id!}
|
organizationId={org.id!}
|
||||||
organizationName={org.name!}
|
organizationName={org.name!}
|
||||||
@@ -207,116 +221,153 @@ export function OrganizationList({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={statusBadge.variant} className="ml-2">
|
|
||||||
|
{/* Desktop Layout */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
{/* Header with org icon, name, role badge and status */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<a
|
||||||
|
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||||
|
className="text-xl font-semibold hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
</a>
|
||||||
|
<Badge
|
||||||
|
variant={org.membershipRole === "member" ? "secondary" : "default"}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{org.membershipRole}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<Badge variant={statusBadge.variant} className="flex items-center gap-1">
|
||||||
{StatusIcon && <StatusIcon className={cn(
|
{StatusIcon && <StatusIcon className={cn(
|
||||||
"h-3 w-3",
|
"h-3.5 w-3.5",
|
||||||
org.status === "mirroring" && "animate-pulse"
|
org.status === "mirroring" && "animate-pulse"
|
||||||
)} />}
|
)} />}
|
||||||
{statusBadge.label}
|
{statusBadge.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground mb-4">
|
{/* Destination override section */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="mb-4">
|
||||||
<span className="font-medium">
|
<MirrorDestinationEditor
|
||||||
{org.repositoryCount}{" "}
|
organizationId={org.id!}
|
||||||
|
organizationName={org.name!}
|
||||||
|
currentDestination={org.destinationOrg}
|
||||||
|
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||||
|
isUpdating={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repository statistics */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-lg">{org.repositoryCount}</span>
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Always render this section to prevent layout shift */}
|
|
||||||
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
|
{/* Repository breakdown */}
|
||||||
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
|
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
|
||||||
<>
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="h-3 w-16" />
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="h-3 w-16" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="flex items-center gap-3">
|
||||||
{org.publicRepositoryCount !== undefined ? (
|
{org.publicRepositoryCount !== undefined && (
|
||||||
<span className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
{org.publicRepositoryCount} public
|
{org.publicRepositoryCount} public
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
|
)}
|
||||||
<span className="flex items-center gap-1">
|
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
|
||||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
{org.privateRepositoryCount} private
|
{org.privateRepositoryCount} private
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
</div>
|
||||||
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
|
||||||
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{/* Show a placeholder if no counts are available to maintain height */}
|
|
||||||
{org.publicRepositoryCount === undefined &&
|
|
||||||
org.privateRepositoryCount === undefined &&
|
|
||||||
org.forkRepositoryCount === undefined && (
|
|
||||||
<span className="invisible">Loading counts...</span>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{/* Mobile Actions */}
|
||||||
|
<div className="flex flex-col gap-3 sm:hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{org.status === "imported" && (
|
{org.status === "imported" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="default"
|
||||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full h-10"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||||
Starting...
|
Starting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Mirror"
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Mirror Organization
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{org.status === "mirroring" && (
|
{org.status === "mirroring" && (
|
||||||
<Button size="sm" disabled variant="outline">
|
<Button size="default" disabled variant="outline" className="w-full h-10">
|
||||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||||
Mirroring...
|
Mirroring...
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{org.status === "mirrored" && (
|
{org.status === "mirrored" && (
|
||||||
<Button size="sm" disabled variant="secondary">
|
<Button size="default" disabled variant="secondary" className="w-full h-10">
|
||||||
<Check className="h-3 w-3 mr-2" />
|
<Check className="h-4 w-4 mr-2" />
|
||||||
Mirrored
|
Mirrored
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{org.status === "failed" && (
|
{org.status === "failed" && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="default"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full h-10"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||||
Retrying...
|
Retrying...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle className="h-3 w-3 mr-2" />
|
<AlertCircle className="h-4 w-4 mr-2" />
|
||||||
Retry
|
Retry Mirror
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
{(() => {
|
{(() => {
|
||||||
const giteaUrl = getGiteaOrgUrl(org);
|
const giteaUrl = getGiteaOrgUrl(org);
|
||||||
|
|
||||||
@@ -337,33 +388,165 @@ export function OrganizationList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return giteaUrl ? (
|
return giteaUrl ? (
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
|
||||||
<a
|
<a
|
||||||
href={giteaUrl}
|
href={giteaUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<SiGitea className="h-4 w-4" />
|
<SiGitea className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="text-xs">Gitea</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
<Button variant="outline" size="default" disabled title={tooltip} className="flex-1 h-10">
|
||||||
<SiGitea className="h-4 w-4" />
|
<SiGitea className="h-4 w-4" />
|
||||||
|
<span className="text-xs ml-2">Gitea</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<Button variant="ghost" size="icon" asChild>
|
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
|
||||||
|
<a
|
||||||
|
href={`https://github.com/${org.name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<SiGithub className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="text-xs">GitHub</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Actions */}
|
||||||
|
<div className="hidden sm:flex items-center justify-between mt-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{org.status === "imported" && (
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Starting mirror...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Mirror Organization
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.status === "mirroring" && (
|
||||||
|
<Button size="default" disabled variant="outline">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Mirroring in progress...
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.status === "mirrored" && (
|
||||||
|
<Button size="default" disabled variant="secondary">
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
Successfully mirrored
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org.status === "failed" && (
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
Retrying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-4 w-4 mr-2" />
|
||||||
|
Retry Mirror
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(() => {
|
||||||
|
const giteaUrl = getGiteaOrgUrl(org);
|
||||||
|
|
||||||
|
// Determine tooltip based on status and configuration
|
||||||
|
let tooltip: string;
|
||||||
|
if (!giteaConfig?.url) {
|
||||||
|
tooltip = "Gitea not configured";
|
||||||
|
} else if (org.status === 'imported') {
|
||||||
|
tooltip = "Organization not yet mirrored to Gitea";
|
||||||
|
} else if (org.status === 'failed') {
|
||||||
|
tooltip = "Organization mirroring failed";
|
||||||
|
} else if (org.status === 'mirroring') {
|
||||||
|
tooltip = "Organization is being mirrored to Gitea";
|
||||||
|
} else if (giteaUrl) {
|
||||||
|
tooltip = "View on Gitea";
|
||||||
|
} else {
|
||||||
|
tooltip = "Gitea organization not available";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center border rounded-md">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild={!!giteaUrl}
|
||||||
|
disabled={!giteaUrl}
|
||||||
|
title={tooltip}
|
||||||
|
className="rounded-none rounded-l-md border-r"
|
||||||
|
>
|
||||||
|
{giteaUrl ? (
|
||||||
|
<a
|
||||||
|
href={giteaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<SiGitea className="h-4 w-4 mr-2" />
|
||||||
|
Gitea
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SiGitea className="h-4 w-4 mr-2" />
|
||||||
|
Gitea
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
className="rounded-none rounded-r-md"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/${org.name}`}
|
href={`https://github.com/${org.name}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title="View on GitHub"
|
title="View on GitHub"
|
||||||
>
|
>
|
||||||
<SiGithub className="h-4 w-4" />
|
<SiGithub className="h-4 w-4 mr-2" />
|
||||||
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,12 +60,12 @@ export default function AddRepositoryDialog({
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
|
<Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
|
||||||
<Plus className="h-6 w-6" />
|
<Plus className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6">
|
<DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Repository</DialogTitle>
|
<DialogTitle>Add Repository</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -18,8 +18,18 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "../ui/select";
|
} from "../ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
|
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter } from "lucide-react";
|
||||||
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
import { useSSE } from "@/hooks/useSEE";
|
import { useSSE } from "@/hooks/useSEE";
|
||||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -71,8 +81,6 @@ export default function Repository() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Received new log:", data);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Use the SSE hook
|
// Use the SSE hook
|
||||||
@@ -559,16 +567,209 @@ export default function Repository() {
|
|||||||
|
|
||||||
const availableActions = getAvailableActions();
|
const availableActions = getAvailableActions();
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
|
||||||
|
const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length;
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
const clearFilters = () => {
|
||||||
|
setFilter({
|
||||||
|
searchTerm: filter.searchTerm,
|
||||||
|
status: "",
|
||||||
|
organization: "",
|
||||||
|
owner: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-8">
|
<div className="flex flex-col gap-y-4 sm:gap-y-8">
|
||||||
{/* Combine search and actions into a single flex row */}
|
{/* Search and filters */}
|
||||||
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
|
||||||
<div className="relative flex-grow min-w-[180px]">
|
{/* Mobile: Search bar with filter button */}
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center gap-2 w-full sm:hidden">
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search repositories..."
|
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"
|
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={filter.searchTerm}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter Drawer */}
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="relative h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[85vh]">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle className="text-lg font-semibold">Filter Repositories</DrawerTitle>
|
||||||
|
<DrawerDescription className="text-sm text-muted-foreground">
|
||||||
|
Narrow down your repository list
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="px-4 py-6 space-y-6 overflow-y-auto">
|
||||||
|
{/* Active filters summary */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Owner Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Owner
|
||||||
|
{filter.owner && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
Selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<OwnerCombobox
|
||||||
|
options={ownerOptions}
|
||||||
|
value={filter.owner || ""}
|
||||||
|
onChange={(owner: string) =>
|
||||||
|
setFilter((prev) => ({ ...prev, owner }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Organization
|
||||||
|
{filter.organization && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
Selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<OrganizationCombobox
|
||||||
|
options={orgOptions}
|
||||||
|
value={filter.organization || ""}
|
||||||
|
onChange={(organization: string) =>
|
||||||
|
setFilter((prev) => ({ ...prev, organization }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">By</span> Status
|
||||||
|
{filter.status && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={filter.status || "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: value === "all" ? "" : (value as RepoStatus),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full h-10">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["all", ...repoStatusEnum.options].map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{status !== "all" && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
status === "synced" ? "bg-green-500" :
|
||||||
|
status === "failed" ? "bg-red-500" :
|
||||||
|
status === "syncing" ? "bg-blue-500" :
|
||||||
|
"bg-yellow-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
{status === "all"
|
||||||
|
? "All statuses"
|
||||||
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button className="w-full" size="sm">
|
||||||
|
Apply Filters
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="outline" className="w-full" size="sm">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
title="Refresh repositories"
|
||||||
|
className="h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleMirrorAllRepos}
|
||||||
|
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||||
|
title="Mirror all repositories"
|
||||||
|
className="h-10 w-10 shrink-0"
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Original layout */}
|
||||||
|
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
|
||||||
|
<div className="relative flex-grow min-w-[180px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search repositories..."
|
||||||
|
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
value={filter.searchTerm}
|
value={filter.searchTerm}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
|
||||||
@@ -594,6 +795,8 @@ export default function Repository() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter controls in a responsive row */}
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
value={filter.status || "all"}
|
value={filter.status || "all"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -603,15 +806,25 @@ export default function Repository() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
<SelectTrigger className="w-[140px] h-10">
|
||||||
<SelectValue placeholder="All Status" />
|
<SelectValue placeholder="All statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{["all", ...repoStatusEnum.options].map((status) => (
|
{["all", ...repoStatusEnum.options].map((status) => (
|
||||||
<SelectItem key={status} value={status}>
|
<SelectItem key={status} value={status}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{status !== "all" && (
|
||||||
|
<span className={`h-2 w-2 rounded-full ${
|
||||||
|
status === "synced" ? "bg-green-500" :
|
||||||
|
status === "failed" ? "bg-red-500" :
|
||||||
|
status === "syncing" ? "bg-blue-500" :
|
||||||
|
"bg-yellow-500"
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
{status === "all"
|
{status === "all"
|
||||||
? "All Status"
|
? "All statuses"
|
||||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -622,22 +835,84 @@ export default function Repository() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
title="Refresh repositories"
|
title="Refresh repositories"
|
||||||
|
className="h-10 w-10 shrink-0"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Context-aware action buttons */}
|
{/* Bulk actions on desktop - integrated into the same line */}
|
||||||
|
<div className="flex items-center gap-2 border-l pl-4">
|
||||||
{selectedRepoIds.size === 0 ? (
|
{selectedRepoIds.size === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={handleMirrorAllRepos}
|
onClick={handleMirrorAllRepos}
|
||||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||||
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
Mirror All
|
Mirror All
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedRepoIds.size} selected
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => setSelectedRepoIds(new Set())}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{availableActions.includes('mirror') && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
onClick={handleBulkMirror}
|
||||||
|
disabled={loadingRepoIds.size > 0}
|
||||||
|
>
|
||||||
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Mirror ({selectedRepoIds.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availableActions.includes('sync') && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
onClick={handleBulkSync}
|
||||||
|
disabled={loadingRepoIds.size > 0}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Sync ({selectedRepoIds.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availableActions.includes('retry') && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
onClick={handleBulkRetry}
|
||||||
|
disabled={loadingRepoIds.size > 0}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons for mobile - only show when items are selected */}
|
||||||
|
{selectedRepoIds.size > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap sm:hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{selectedRepoIds.size} selected
|
{selectedRepoIds.size} selected
|
||||||
@@ -652,6 +927,7 @@ export default function Repository() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
{availableActions.includes('mirror') && (
|
{availableActions.includes('mirror') && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -660,7 +936,7 @@ export default function Repository() {
|
|||||||
disabled={loadingRepoIds.size > 0}
|
disabled={loadingRepoIds.size > 0}
|
||||||
>
|
>
|
||||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
Mirror ({selectedRepoIds.size})
|
<span>Mirror </span>({selectedRepoIds.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -672,7 +948,7 @@ export default function Repository() {
|
|||||||
disabled={loadingRepoIds.size > 0}
|
disabled={loadingRepoIds.size > 0}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Sync ({selectedRepoIds.size})
|
<span className="hidden sm:inline">Sync </span>({selectedRepoIds.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -688,8 +964,8 @@ export default function Repository() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isGitHubConfigured ? (
|
{!isGitHubConfigured ? (
|
||||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-md">
|
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-md">
|
||||||
@@ -721,7 +997,9 @@ export default function Repository() {
|
|||||||
loadingRepoIds={loadingRepoIds}
|
loadingRepoIds={loadingRepoIds}
|
||||||
selectedRepoIds={selectedRepoIds}
|
selectedRepoIds={selectedRepoIds}
|
||||||
onSelectionChange={setSelectedRepoIds}
|
onSelectionChange={setSelectedRepoIds}
|
||||||
onRefresh={() => fetchRepositories(false)}
|
onRefresh={async () => {
|
||||||
|
await fetchRepositories(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -33,17 +33,22 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="w-[160px] justify-between"
|
className="w-full sm:w-[160px] justify-between h-10"
|
||||||
>
|
>
|
||||||
{value ? value : placeholder}
|
<span className={cn(
|
||||||
|
"truncate",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{value || "All owners"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[160px] p-0">
|
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
<CommandInput placeholder="Search owners..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
|
<CommandEmpty>No owners found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key="all"
|
key="all"
|
||||||
@@ -54,7 +59,7 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||||
All
|
All owners
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -86,17 +91,22 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="w-[160px] justify-between"
|
className="w-full sm:w-[160px] justify-between h-10"
|
||||||
>
|
>
|
||||||
{value ? value : placeholder}
|
<span className={cn(
|
||||||
|
"truncate",
|
||||||
|
!value && "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{value || "All organizations"}
|
||||||
|
</span>
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[160px] p-0">
|
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
<CommandInput placeholder="Search organizations..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
|
<CommandEmpty>No organizations found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key="all"
|
key="all"
|
||||||
@@ -107,7 +117,7 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
|
||||||
All
|
All organizations
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react";
|
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock } from "lucide-react";
|
||||||
import { SiGithub, SiGitea } from "react-icons/si";
|
import { SiGithub, SiGitea } from "react-icons/si";
|
||||||
import type { Repository } from "@/lib/db/schema";
|
import type { Repository } from "@/lib/db/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
interface RepositoryTableProps {
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
@@ -166,8 +168,202 @@ export default function RepositoryTable({
|
|||||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||||
|
|
||||||
|
// Mobile card layout for repository
|
||||||
|
const RepositoryCard = ({ repo }: { repo: Repository }) => {
|
||||||
|
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
|
||||||
|
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
|
||||||
|
const giteaUrl = getGiteaRepoUrl(repo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Header with checkbox and repo name */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
|
||||||
|
className="mt-1 h-5 w-5"
|
||||||
|
aria-label={`Select ${repo.name}`}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium text-base truncate">{repo.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
{repo.isPrivate && <Badge variant="secondary" className="text-xs h-5"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
|
||||||
|
{repo.isForked && <Badge variant="secondary" className="text-xs h-5"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
|
||||||
|
{repo.isStarred && <Badge variant="secondary" className="text-xs h-5"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repository details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Owner & Organization */}
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Owner:</span>
|
||||||
|
<span className="truncate">{repo.owner}</span>
|
||||||
|
</div>
|
||||||
|
{repo.organization && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Org:</span>
|
||||||
|
<span className="truncate">{repo.organization}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{repo.destinationOrg && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Dest:</span>
|
||||||
|
<span className="truncate">{repo.destinationOrg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status & Last Mirrored */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`h-2.5 w-2.5 rounded-full ${getStatusColor(repo.status)}`} />
|
||||||
|
<span className="text-sm font-medium capitalize">{repo.status}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Primary action button */}
|
||||||
|
{(repo.status === "imported" || repo.status === "failed") && (
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => repo.id && onMirror({ repoId: repo.id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full h-10"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<FlipHorizontal className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Mirroring...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
Mirror Repository
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(repo.status === "mirrored" || repo.status === "synced") && (
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => repo.id && onSync({ repoId: repo.id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full h-10"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Syncing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Sync Repository
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{repo.status === "failed" && (
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => repo.id && onRetry({ repoId: repo.id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full h-10"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Retrying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />
|
||||||
|
Retry Mirror
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External links */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
|
||||||
|
<a
|
||||||
|
href={repo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<SiGithub className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="text-xs">GitHub</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{giteaUrl ? (
|
||||||
|
<Button variant="outline" size="default" className="flex-1 h-10 min-w-0" asChild>
|
||||||
|
<a
|
||||||
|
href={giteaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="View on Gitea"
|
||||||
|
className="flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<SiGitea className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="text-xs">Gitea</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="default" disabled className="flex-1 h-10 min-w-0">
|
||||||
|
<SiGitea className="h-4 w-4" />
|
||||||
|
<span className="text-xs ml-2">Gitea</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
<div className="border rounded-md">
|
<div className="space-y-3 lg:space-y-0">
|
||||||
|
{/* Mobile skeleton */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i} className="mb-3">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Skeleton className="h-4 w-4 mt-1" />
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop skeleton */}
|
||||||
|
<div className="hidden lg:block border rounded-md">
|
||||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Skeleton className="h-4 w-4" />
|
<Skeleton className="h-4 w-4" />
|
||||||
@@ -199,65 +395,95 @@ export default function RepositoryTable({
|
|||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Skeleton className="h-4 w-4" />
|
<Skeleton className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
<div className="h-full p-3 flex-[2.5]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-3 w-24 mt-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredRepositories.length === 0 ? (
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
) : (
|
||||||
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
|
<div>
|
||||||
<h3 className="text-lg font-medium">No repositories found</h3>
|
{hasAnyFilter && (
|
||||||
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
{hasAnyFilter
|
<span className="text-sm text-muted-foreground">
|
||||||
? "Try adjusting your search or filter criteria."
|
Showing {filteredRepositories.length} of {repositories.length} repositories
|
||||||
: "Configure your GitHub connection to start mirroring repositories."}
|
</span>
|
||||||
</p>
|
|
||||||
{hasAnyFilter ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter({
|
setFilter({
|
||||||
searchTerm: "",
|
searchTerm: "",
|
||||||
status: "",
|
status: "",
|
||||||
|
organization: "",
|
||||||
|
owner: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Clear Filters
|
Clear filters
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<a href="/config">Configure GitHub</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{filteredRepositories.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{hasAnyFilter
|
||||||
|
? "No repositories match the current filters"
|
||||||
|
: "No repositories found"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col border rounded-md">
|
<>
|
||||||
{/* table header */}
|
{/* Mobile card view */}
|
||||||
|
<div className="lg:hidden pb-20">
|
||||||
|
{/* Select all checkbox */}
|
||||||
|
<div className="flex items-center gap-3 mb-3 p-3 bg-muted/50 rounded-md">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
aria-label="Select all repositories"
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Select All ({filteredRepositories.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repository cards */}
|
||||||
|
{filteredRepositories.map((repo) => (
|
||||||
|
<RepositoryCard key={repo.id} repo={repo} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop table view */}
|
||||||
|
<div className="hidden lg: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-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
indeterminate={isPartiallySelected}
|
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
aria-label="Select all repositories"
|
aria-label="Select all repositories"
|
||||||
/>
|
/>
|
||||||
@@ -281,10 +507,10 @@ export default function RepositoryTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* table body wrapper (for a parent in virtualization) */}
|
{/* Table body wrapper (for a parent in virtualization) */}
|
||||||
<div
|
<div
|
||||||
ref={tableParentRef}
|
ref={tableParentRef}
|
||||||
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
|
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -308,7 +534,7 @@ export default function RepositoryTable({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
data-index={virtualRow.index}
|
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
|
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
{/* Checkbox */}
|
{/* Checkbox */}
|
||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
@@ -344,7 +570,6 @@ export default function RepositoryTable({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner */}
|
{/* Owner */}
|
||||||
<div className="h-full p-3 flex items-center flex-[1]">
|
<div className="h-full p-3 flex items-center flex-[1]">
|
||||||
<p className="text-sm">{repo.owner}</p>
|
<p className="text-sm">{repo.owner}</p>
|
||||||
@@ -392,7 +617,6 @@ export default function RepositoryTable({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="h-full p-3 flex items-center justify-start flex-[1]">
|
<div className="h-full p-3 flex items-center justify-start flex-[1]">
|
||||||
<RepoActionButton
|
<RepoActionButton
|
||||||
@@ -403,7 +627,6 @@ export default function RepositoryTable({
|
|||||||
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
|
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -499,6 +722,9 @@ export default function RepositoryTable({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +757,7 @@ function RepoActionButton({
|
|||||||
disabled ||= repo.status === "syncing";
|
disabled ||= repo.status === "syncing";
|
||||||
} else if (["imported", "mirroring"].includes(repo.status)) {
|
} else if (["imported", "mirroring"].includes(repo.status)) {
|
||||||
label = "Mirror";
|
label = "Mirror";
|
||||||
icon = <FlipHorizontal className="h-4 w-4 mr-1" />; // Don't change this icon to GitFork.
|
icon = <FlipHorizontal className="h-4 w-4 mr-1" />;
|
||||||
onClick = onMirror;
|
onClick = onMirror;
|
||||||
disabled ||= repo.status === "mirroring";
|
disabled ||= repo.status === "mirroring";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
133
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-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 DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
@@ -38,9 +38,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// setIsLoading(true);
|
// setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const user = await authApi.getCurrentUser();
|
const user = await authApi.getCurrentUser();
|
||||||
|
|
||||||
console.log("User data refreshed:", user);
|
|
||||||
|
|
||||||
setUser(user);
|
setUser(user);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ export const useSSE = ({
|
|||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
setReconnectCount(0); // Reset reconnect counter on successful connection
|
setReconnectCount(0); // Reset reconnect counter on successful connection
|
||||||
console.log(`Connected to SSE for user: ${userId}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export function useRepoSync({
|
|||||||
|
|
||||||
const sync = async () => {
|
const sync = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Attempting to sync...");
|
|
||||||
const response = await fetch("/api/job/schedule-sync-repo", {
|
const response = await fetch("/api/job/schedule-sync-repo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -68,7 +67,6 @@ export function useRepoSync({
|
|||||||
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
|
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();
|
const result = await response.json();
|
||||||
console.log("Sync successful:", result);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Sync failed:", error);
|
console.error("Sync failed:", error);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type RepositoryVisibility,
|
type RepositoryVisibility,
|
||||||
type RepoStatus,
|
type RepoStatus,
|
||||||
} from "@/types/Repository";
|
} from "@/types/Repository";
|
||||||
|
import { membershipRoleEnum } from "@/types/organizations";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import type { Config } from "@/types/config";
|
import type { Config } from "@/types/config";
|
||||||
import type { Organization, Repository } from "./db/schema";
|
import type { Organization, Repository } from "./db/schema";
|
||||||
@@ -22,13 +23,26 @@ export const getOrganizationConfig = async ({
|
|||||||
userId: string;
|
userId: string;
|
||||||
}): Promise<Organization | null> => {
|
}): Promise<Organization | null> => {
|
||||||
try {
|
try {
|
||||||
const [orgConfig] = await db
|
const result = await db
|
||||||
.select()
|
.select()
|
||||||
.from(organizations)
|
.from(organizations)
|
||||||
.where(and(eq(organizations.name, orgName), eq(organizations.userId, userId)))
|
.where(and(eq(organizations.name, orgName), eq(organizations.userId, userId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return orgConfig || null;
|
if (!result[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and cast the membershipRole to ensure type safety
|
||||||
|
const rawOrg = result[0];
|
||||||
|
const membershipRole = membershipRoleEnum.parse(rawOrg.membershipRole);
|
||||||
|
const status = repoStatusEnum.parse(rawOrg.status);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rawOrg,
|
||||||
|
membershipRole,
|
||||||
|
status,
|
||||||
|
} as Organization;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error fetching organization config for ${orgName}:`, error);
|
console.error(`Error fetching organization config for ${orgName}:`, error);
|
||||||
return null;
|
return null;
|
||||||
@@ -113,7 +127,7 @@ export const getGiteaRepoOwner = ({
|
|||||||
|
|
||||||
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
|
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
|
||||||
const mirrorStrategy = config.giteaConfig.mirrorStrategy ||
|
const mirrorStrategy = config.giteaConfig.mirrorStrategy ||
|
||||||
(config.githubConfig.preserveOrgStructure ? "preserve" : "flat-user");
|
(config.giteaConfig.preserveOrgStructure ? "preserve" : "flat-user");
|
||||||
|
|
||||||
switch (mirrorStrategy) {
|
switch (mirrorStrategy) {
|
||||||
case "preserve":
|
case "preserve":
|
||||||
@@ -871,7 +885,7 @@ export async function mirrorGitHubOrgToGitea({
|
|||||||
|
|
||||||
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
|
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
|
||||||
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
|
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
|
||||||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
|
(config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
|
||||||
|
|
||||||
let giteaOrgId: number;
|
let giteaOrgId: number;
|
||||||
let targetOrgName: string;
|
let targetOrgName: string;
|
||||||
|
|||||||
@@ -146,3 +146,13 @@
|
|||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: oklch(0.6 0 0);
|
background-color: oklch(0.6 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||