Compare commits

...

16 Commits

Author SHA1 Message Date
Arunavo Ray
4958b8b9ec Release v2.22.0
### 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
- Mobile screenshots in documentation

### Improved
- Enhanced mobile user experience with optimized layouts
- 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
2025-07-08 00:17:19 +05:30
ARUNAVO RAY
aedf0c23b8 Update README.md 2025-07-07 23:50:48 +05:30
ARUNAVO RAY
b4937d1e01 Update README.md 2025-07-07 23:50:16 +05:30
ARUNAVO RAY
498968695f Update README.md 2025-07-07 23:49:42 +05:30
Arunavo Ray
d0e8e754a7 Type fix 2025-07-07 23:37:52 +05:30
Arunavo Ray
c95a501974 Removed some console.logs 2025-07-07 23:12:26 +05:30
Arunavo Ray
fd8f782f34 Updated org list cards 2025-07-07 23:07:22 +05:30
Arunavo Ray
02ff865e4b Updated Screenshots 2025-07-07 23:07:00 +05:30
Arunavo Ray
df9da165c8 Added mobile layout screenshots 2025-07-07 22:52:33 +05:30
ARUNAVO RAY
180f300752 Merge pull request #40 from RayLabsHQ/mobile-layout
Mobile layout Optimised
2025-07-07 22:30:12 +05:30
Arunavo Ray
472f67a6ae Updates to Repository and Org Pages for Responsive Layouts 2025-07-07 22:02:43 +05:30
Arunavo Ray
6270907e70 Updates for mobile 2025-07-07 20:24:09 +05:30
Arunavo Ray
1deaae4d34 More responsive layout updates to Config Page 2025-07-07 19:27:07 +05:30
Arunavo Ray
b984ff9af4 feat: improve mobile layout across components
- Update ActivityLog component for better mobile responsiveness
- Enhance Header layout for mobile devices
- Improve mobile UX in AddOrganizationDialog
- Optimize Organization component mobile display
- Enhance AddRepositoryDialog mobile layout
2025-07-07 18:51:24 +05:30
Arunavo Ray
24bd0aefe6 Added basic responsive layout 2025-07-07 17:34:54 +05:30
Arunavo Ray
6155e39360 docs: document docker-compose.alt.yml for quick start 2025-07-07 16:36:56 +05:30
43 changed files with 2597 additions and 959 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 KiB

After

Width:  |  Height:  |  Size: 854 KiB

BIN
.github/assets/activity_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 891 KiB

After

Width:  |  Height:  |  Size: 950 KiB

BIN
.github/assets/configuration_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

BIN
.github/assets/dashboard_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

BIN
.github/assets/organisation.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

BIN
.github/assets/organisation_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

BIN
.github/assets/repositories_mobile.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
### Fixed

View File

@@ -13,16 +13,17 @@
## 🚀 Quick Start
```bash
# Using Docker (recommended)
docker compose up -d
# Fastest way - using the simplified Docker setup
docker compose -f docker-compose.alt.yml up -d
# 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">
<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>
## ✨ Features
@@ -37,27 +38,71 @@ First user signup becomes admin. No configuration needed to get started!
## 📸 Screenshots
<p align="center">
<img src=".github/assets/repositories.png" width="49%"/>
<img src=".github/assets/organisations.png" width="49%"/>
</p>
<div align="center">
<img src=".github/assets/repositories.png" alt="Repositories" width="600" />
<img src=".github/assets/repositories_mobile.png" alt="Rrepositories Mobile" width="200" />
</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
### 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
# Clone repository
git clone https://github.com/RayLabsHQ/gitea-mirror.git
cd gitea-mirror
# Start with Docker Compose
docker compose up -d
# Start with simplified setup
docker compose -f docker-compose.alt.yml up -d
# 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
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
Create a `.env` file for custom settings (optional):
#### Quick Start Configuration (docker-compose.alt.yml)
Minimal `.env` file (optional - has sensible defaults):
```bash
# JWT secret for authentication (auto-generated if blank or default below)
JWT_SECRET=your-secret-key-change-this-in-production
# Port configuration
# Custom port (default: 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)
```bash
@@ -150,4 +207,4 @@ GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
- 🔐 [Custom CA Certificates](docs/CA_CERTIFICATES.md)
- 🐛 [Report Issues](https://github.com/RayLabsHQ/gitea-mirror/issues)
- 💬 [Discussions](https://github.com/RayLabsHQ/gitea-mirror/discussions)
- 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)
- 🔧 [Proxmox VE Script](https://community-scripts.github.io/ProxmoxVE/scripts?id=gitea-mirror)

View File

@@ -51,6 +51,7 @@
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.75",
},
"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=="],
"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-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "2.21.0",
"version": "2.22.0",
"engines": {
"bun": ">=1.2.9"
},
@@ -78,6 +78,7 @@
"tw-animate-css": "^1.3.5",
"typescript": "^5.8.3",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.75"
},
"devDependencies": {

View File

@@ -3,11 +3,17 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import type { MirrorJob } from '@/lib/db/schema';
import Fuse from 'fuse.js';
import { Button } from '../ui/button';
import { RefreshCw } from 'lucide-react';
import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react';
import { Card } from '../ui/card';
import { formatDate, getStatusColor } from '@/lib/utils';
import { Skeleton } from '../ui/skeleton';
import type { FilterParams } from '@/types/filter';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -73,7 +79,7 @@ export default function ActivityList({
count: filteredActivities.length,
getScrollElement: () => parentRef.current,
estimateSize: (idx) =>
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120,
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100,
overscan: 5,
measureElement: (el) => el.getBoundingClientRect().height + 8,
});
@@ -155,8 +161,8 @@ export default function ActivityList({
}}
className='border-b px-4 pt-4'
>
<div className='flex items-start gap-4'>
<div className='relative mt-2'>
<div className='flex items-start gap-3 sm:gap-4'>
<div className='relative mt-2 flex-shrink-0'>
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
activity.status,
@@ -164,25 +170,112 @@ export default function ActivityList({
/>
</div>
<div className='flex-1'>
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'>
<p className='font-medium'>{activity.message}</p>
<p className='text-sm text-muted-foreground'>
<div className='flex-1 min-w-0'>
<div className='mb-1 flex items-start justify-between gap-2'>
<div className='flex-1 min-w-0'>
{/* 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)}
</p>
</div>
{activity.repositoryName && (
<p className='mb-2 text-sm text-muted-foreground'>
Repository: {activity.repositoryName}
</p>
)}
{activity.organizationName && (
<p className='mb-2 text-sm text-muted-foreground'>
Organization: {activity.organizationName}
</p>
)}
<div className='flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3'>
{activity.repositoryName && (
<p className='text-sm text-muted-foreground truncate'>
<span className='font-medium'>Repo:</span> {activity.repositoryName}
</p>
)}
{activity.organizationName && (
<p className='text-sm text-muted-foreground truncate'>
<span className='font-medium'>Org:</span> {activity.organizationName}
</p>
)}
</div>
{activity.details && (
<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>
{isExpanded && (

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useRef } from 'react';
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 {
DropdownMenu,
DropdownMenuContent,
@@ -36,6 +36,16 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -343,18 +353,225 @@ export function ActivityLog() {
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 ------------------------------ */
return (
<div className='flex flex-col gap-y-8'>
<div className='flex w-full flex-row items-center gap-4'>
<div className='flex flex-col gap-y-4 sm:gap-y-8'>
{/* Mobile: Search bar with filter and action buttons */}
<div className="flex flex-col gap-2 sm:hidden">
<div className="flex items-center gap-2 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 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}
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-2 top-2.5 h-4 w-4 text-muted-foreground' />
<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='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'
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}
onChange={(e) =>
setFilter((prev) => ({
@@ -365,27 +582,66 @@ export function ActivityLog() {
/>
</div>
{/* status select */}
<Select
value={filter.status || 'all'}
onValueChange={(v) =>
setFilter((p) => ({
...p,
status: v === 'all' ? '' : (v as RepoStatus),
}))
}
>
<SelectTrigger className='h-9 w-[140px] max-h-9'>
<SelectValue placeholder='All Status' />
</SelectTrigger>
<SelectContent>
{['all', ...repoStatusEnum.options].map((s) => (
<SelectItem key={s} value={s}>
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Filter controls */}
<div className="flex items-center gap-2">
{/* status select */}
<Select
value={filter.status || 'all'}
onValueChange={(v) =>
setFilter((p) => ({
...p,
status: v === 'all' ? '' : (v as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] 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>
{/* type select */}
<Select
value={filter.type || 'all'}
onValueChange={(v) =>
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
}
>
<SelectTrigger className="w-[140px] 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>
{/* repo/org name combobox */}
<ActivityNameCombobox
@@ -394,64 +650,49 @@ export function ActivityLog() {
onChange={(name) => setFilter((p) => ({ ...p, name }))}
/>
{/* type select */}
<Select
value={filter.type || 'all'}
onValueChange={(v) =>
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
}
>
<SelectTrigger className='h-9 w-[140px] max-h-9'>
<SelectValue placeholder='All Types' />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}>
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-auto">
{/* export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-10">
<Download className="h-4 w-4 mr-2" />
Export
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* export dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' className='flex items-center gap-1'>
<Download className='mr-1 h-4 w-4' />
Export
<ChevronDown className='ml-1 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* refresh */}
<Button
variant="outline"
size="icon"
onClick={() => fetchActivities(false)}
title="Refresh activity log"
className="h-10 w-10"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* refresh */}
<Button
variant="outline"
size="icon"
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
title="Refresh activity log"
>
<RefreshCw className='h-4 w-4' />
</Button>
{/* cleanup all activities */}
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
className="text-destructive hover:text-destructive"
>
<Trash2 className='h-4 w-4' />
</Button>
{/* cleanup all activities */}
<Button
variant="outline"
size="icon"
onClick={handleCleanupClick}
title="Delete all activities"
className="text-destructive hover:text-destructive h-10 w-10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* activity list */}
@@ -486,6 +727,26 @@ export function ActivityLog() {
</DialogFooter>
</DialogContent>
</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>
);
}

View File

@@ -41,9 +41,14 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
variant="outline"
role="combobox"
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" />
</Button>
</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")} />
All Names
All names
</CommandItem>
{names.map((name) => (
<CommandItem

View File

@@ -98,6 +98,25 @@ export function AutomationSettings({
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Zap className="h-5 w-5" />
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>
</CardHeader>
@@ -311,21 +330,6 @@ export function AutomationSettings({
</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>
</Card>
);

View File

@@ -563,17 +563,17 @@ export function ConfigTabs() {
) : (
<div className="space-y-6">
{/* 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">
<h1 className="text-2xl font-semibold leading-none tracking-tight">
Configuration Settings
Configuration
</h1>
<p className="text-sm text-muted-foreground">
Configure your GitHub and Gitea connections, and set up automatic
mirroring.
</p>
</div>
<div className="flex gap-x-4">
<div className="flex gap-x-4 w-full md:w-auto">
<Button
onClick={handleImportGitHubData}
disabled={isSyncing || !isGitHubConfigValid()}
@@ -584,6 +584,7 @@ export function ConfigTabs() {
? 'Import in progress'
: 'Import GitHub Data'
}
className="w-full md:w-auto"
>
{isSyncing ? (
<>

View File

@@ -88,15 +88,17 @@ export function GitHubConfigForm({
return (
<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">
GitHub Configuration
</CardTitle>
{/* Desktop: Show button in header */}
<Button
type="button"
variant="outline"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
@@ -200,6 +202,17 @@ export function GitHubConfigForm({
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>
</Card>

View File

@@ -124,7 +124,7 @@ export function GitHubMirrorSettings({
</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">
<Checkbox
id="starred-repos"
@@ -145,10 +145,10 @@ export function GitHubMirrorSettings({
</div>
</div>
{/* Starred repos content selection - inline to prevent layout shift */}
{/* Starred repos content selection - responsive layout */}
<div className={cn(
"flex items-center justify-end transition-opacity duration-200",
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 hidden pointer-events-none"
)}>
<Popover>
<PopoverTrigger asChild>
@@ -156,7 +156,7 @@ export function GitHubMirrorSettings({
variant="outline"
size="sm"
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>
{advancedOptions.skipStarredIssues ? (
@@ -325,7 +325,7 @@ export function GitHubMirrorSettings({
</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">
<Checkbox
id="mirror-metadata"
@@ -346,10 +346,10 @@ export function GitHubMirrorSettings({
</div>
</div>
{/* Metadata multi-select - inline to prevent layout shift */}
{/* Metadata multi-select - responsive layout */}
<div className={cn(
"flex items-center justify-end transition-opacity duration-200",
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 pointer-events-none"
"flex items-center justify-end transition-opacity duration-200 mt-3 md:mt-0",
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 hidden pointer-events-none"
)}>
<Popover>
<PopoverTrigger asChild>
@@ -357,7 +357,7 @@ export function GitHubMirrorSettings({
variant="outline"
size="sm"
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>
{(() => {

View File

@@ -136,15 +136,17 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
return (
<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">
Gitea Configuration
</CardTitle>
{/* Desktop: Show button in header */}
<Button
type="button"
variant="outline"
variant="default"
onClick={testConnection}
disabled={isLoading || !config.url || !config.token}
className="hidden sm:inline-flex"
>
{isLoading ? "Testing..." : "Test Connection"}
</Button>
@@ -252,6 +254,17 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
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>
</Card>
);

View File

@@ -183,7 +183,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
<Icon className="h-3.5 w-3.5" />
<span>{option.label}</span>
</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>
</TooltipTrigger>
<TooltipContent>

View File

@@ -24,7 +24,7 @@ const strategyConfig = {
preserve: {
title: "Preserve Structure",
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",
bgColor: "bg-blue-50 dark:bg-blue-950/20",
borderColor: "border-blue-200 dark:border-blue-900",
@@ -60,7 +60,7 @@ const strategyConfig = {
"mixed": {
title: "Mixed Mode",
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",
bgColor: "bg-orange-50 dark:bg-orange-950/20",
borderColor: "border-orange-200 dark:border-orange-900",
@@ -281,7 +281,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
}) => {
return (
<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">
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
<Building className="h-4 w-4" />
@@ -303,7 +303,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
<span>Override Options</span>
</button>
</HoverCardTrigger>
<HoverCardContent side="left" align="start" className="w-[380px]">
<HoverCardContent side="bottom" align="start" className="w-[380px]">
<div className="space-y-3">
<div>
<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"
)}
>
<div className="p-3">
<div className="flex items-center gap-3">
<div className="p-3 sm:p-4">
<div className="flex items-start gap-3">
<RadioGroupItem
value={key}
id={key}
className="mt-1"
/>
<div className={cn(
"rounded-lg p-2",
"rounded-lg p-2 flex-shrink-0",
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
)}>
<Icon className={cn(
@@ -388,38 +389,40 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
)} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-sm">{config.title}</h4>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{config.description}
</p>
</div>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
className="inline-flex p-1.5 hover:bg-muted rounded-md transition-colors cursor-help"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-4 w-4 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h4 className="font-medium text-sm">{config.title}</h4>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{config.description}
</p>
</div>
</HoverCardContent>
</HoverCard>
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<span
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()}
>
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
</span>
</HoverCardTrigger>
<HoverCardContent side="left" align="center" className="w-[500px]">
<div className="space-y-3">
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
<MappingPreview
strategy={key}
config={config}
destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg}
githubUsername={githubUsername}
giteaUsername={giteaUsername}
/>
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
</div>
</div>
</Card>

View File

@@ -57,8 +57,6 @@ export function Dashboard() {
}
setActivities((prevActivities) => [data, ...prevActivities]);
console.log("Received new log:", data);
}, []);
// Use the SSE hook
@@ -179,16 +177,16 @@ export function Dashboard() {
return isLoading || !connected ? (
<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 />
</div>
<div className="flex gap-x-6 items-start">
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* 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">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
@@ -201,7 +199,7 @@ export function Dashboard() {
</div>
{/* 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">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-9 w-24" />
@@ -217,24 +215,24 @@ export function Dashboard() {
) : (
<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
title="Total Repositories"
title="Repositories"
value={repoCount}
icon={<GitFork className="h-4 w-4" />}
description="Repositories being mirrored"
description="Total in mirror queue"
/>
<StatusCard
title="Mirrored"
value={mirroredCount}
icon={<FlipHorizontal className="h-4 w-4" />}
description="Successfully mirrored"
description="Synced to Gitea"
/>
<StatusCard
title="Organizations"
value={orgCount}
icon={<Building2 className="h-4 w-4" />}
description="GitHub organizations"
description="From GitHub"
/>
<StatusCard
title="Last Sync"
@@ -254,11 +252,15 @@ export function Dashboard() {
/>
</div>
<div className="flex gap-x-6 items-start">
<RepositoryList repositories={repositories} />
<div className="flex flex-col lg:flex-row gap-6 items-start">
<div className="w-full lg:w-1/2">
<RepositoryList repositories={repositories} />
</div>
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
<RecentActivity activities={activities.slice(0, 10)} />
<div className="w-full lg:w-1/2">
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
<RecentActivity activities={activities.slice(0, 10)} />
</div>
</div>
</div>
);

View File

@@ -16,7 +16,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
<a href="/activity">View All</a>
</Button>
</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">
{activities.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity</p>
@@ -31,7 +31,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
/>
</div>
<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}
</p>
<p className="text-xs text-muted-foreground">

View File

@@ -54,7 +54,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
<a href="/repositories">View All</a>
</Button>
</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 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
@@ -71,11 +71,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
{repositories.map((repo, index) => (
<div
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 items-center gap-2">
<h4 className="text-sm font-medium">{repo.name}</h4>
<div className="flex items-center flex-wrap gap-2">
<h4 className="text-sm font-medium break-all">{repo.name}</h4>
{repo.isPrivate && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
Private
@@ -99,13 +99,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 sm:ml-auto">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
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 */}
{repo.status}
</span>

View File

@@ -7,13 +7,21 @@ import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu, LogOut } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
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 { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -54,39 +62,52 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
return (
<header className="border-b bg-background">
<div className="flex h-[4.5rem] items-center justify-between px-6">
<button
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 hidden dark:block"
/>
<span className="text-xl font-bold">Gitea Mirror</span>
</button>
<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
onClick={() => {
if (currentPage !== 'dashboard') {
window.history.pushState({}, '', '/');
onNavigate?.('dashboard');
}
}}
className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
>
<img
src="/logo-light.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 dark:hidden"
/>
<img
src="/logo-dark.svg"
alt="Gitea Mirror Logo"
className="h-6 w-6 hidden dark:block"
/>
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
</button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 sm:gap-4">
{showLiveButton && (
<Button
variant="outline"
size="lg"
className="flex items-center gap-2"
className="flex items-center gap-1.5 px-3 sm:px-4"
onClick={toggleLive}
title={getTooltip()}
>
<div className={`w-3 h-3 rounded-full ${
<div className={`size-4 sm:size-3 rounded-full ${
configLoading
? 'bg-yellow-400 animate-pulse'
: isLiveActive
@@ -95,7 +116,7 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
? 'bg-orange-400'
: 'bg-gray-500'
}`} />
<span>LIVE</span>
<span className="text-sm font-medium hidden sm:inline">LIVE</span>
</Button>
)}
@@ -104,19 +125,26 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
{isLoading ? (
<AuthButtonsSkeleton />
) : user ? (
<>
<Avatar>
<AvatarImage src="" alt="@shadcn" />
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<Button variant="outline" size="lg" onClick={handleLogout}>
Logout
</Button>
</>
<DropdownMenu>
<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" />
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</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>
</Button>
)}

View File

@@ -44,6 +44,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
const { isLoading: configLoading } = useConfigStatus();
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
const [navigationKey, setNavigationKey] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
useRepoSync({
userId: user?.id,
@@ -99,10 +100,18 @@ function AppWithProviders({ page: initialPage }: AppProps) {
return (
<NavigationContext.Provider value={{ navigationKey }}>
<main className="flex min-h-screen flex-col">
<Header currentPage={currentPage} onNavigate={handleNavigation} />
<div className="flex flex-1">
<Sidebar onNavigate={handleNavigation} />
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
<Header
currentPage={currentPage}
onNavigate={handleNavigation}
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 === "repositories" && <Repository />}
{currentPage === "organizations" && <Organization />}

View File

@@ -7,16 +7,17 @@ import { VersionInfo } from "./VersionInfo";
interface SidebarProps {
className?: string;
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>("");
useEffect(() => {
// Hydration happens here
const path = window.location.pathname;
setCurrentPath(path);
console.log("Hydrated path:", path); // Should log now
}, []);
// Listen for URL changes (browser back/forward)
@@ -50,53 +51,77 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
const pageName = pageMap[href] || 'dashboard';
onNavigate?.(pageName);
// Close sidebar on mobile after navigation
if (window.innerWidth < 1024) {
onClose();
}
};
return (
<aside className={cn("w-64 border-r bg-background", className)}>
<div className="flex flex-col h-full pt-4">
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
{links.map((link, index) => {
const isActive = currentPath === link.href;
const Icon = link.icon;
<>
{/* Mobile Backdrop */}
{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) => {
const isActive = currentPath === link.href;
const Icon = link.icon;
return (
<button
key={index}
onClick={(e) => handleNavigation(link.href, e)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
return (
<button
key={index}
onClick={(e) => handleNavigation(link.href, e)}
className={cn(
"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
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-5 w-5 lg:h-4 lg:w-4" />
{link.label}
</button>
);
})}
</nav>
<div className="flex-1 min-h-0" />
<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>
<p className="text-xs text-muted-foreground mb-3 lg:mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
>
<Icon className="h-4 w-4" />
{link.label}
</button>
);
})}
</nav>
<div className="mt-auto px-4 py-4">
<div className="rounded-md bg-muted p-3">
<h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-2">
Check out the documentation for help with setup and configuration.
</p>
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
Documentation
<ExternalLink className="h-3 w-3" />
</a>
Documentation
<ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
</a>
</div>
<VersionInfo />
</div>
<VersionInfo />
</div>
</div>
</aside>
</aside>
</>
);
}

View File

@@ -63,12 +63,12 @@ export default function AddOrganizationDialog({
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
<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" />
</Button>
</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>
<DialogTitle>Add Organization</DialogTitle>
<DialogDescription>

View File

@@ -69,19 +69,19 @@ export function MirrorDestinationEditor({
};
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Building2 className="h-3 w-3" />
<span className="font-medium">{organizationName}</span>
<ArrowRight className="h-3 w-3" />
<div className={cn("flex items-center gap-2 w-full", className)}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground min-w-0 flex-1">
<Building2 className="h-3 w-3 flex-shrink-0" />
<span className="font-medium truncate">{organizationName}</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className={cn(
"font-medium",
"font-medium truncate",
hasOverride && "text-orange-600 dark:text-orange-400"
)}>
{effectiveDestination}
</span>
{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
</Badge>
)}
@@ -92,11 +92,11 @@ export function MirrorDestinationEditor({
<Button
size="sm"
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"
disabled={isUpdating || isLoading}
>
<Edit3 className="h-3 w-3" />
<Edit3 className="h-5 w-5 sm:h-3 sm:w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
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 { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog";
@@ -27,6 +27,16 @@ import { toast } from "sonner";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -54,8 +64,6 @@ export function Organization() {
)
);
}
console.log("Received new log:", data);
}, []);
// Use the SSE hook
@@ -290,110 +298,351 @@ 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 (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
<div className="relative flex-grow">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search Organizations..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
<div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Search and filters */}
<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">
<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>
{/* 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>
{/* Membership Role Filter */}
<Select
value={filter.membershipRole || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
membershipRole: value === "all" ? "" : (value as MembershipRole),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Roles" />
</SelectTrigger>
<SelectContent>
{["all", ...membershipRoleEnum.options].map((role) => (
<SelectItem key={role} value={role}>
{role === "all"
? "All Roles"
: role
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 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>
{/* Status Filter */}
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status:
value === "all"
? ""
: (value as
| ""
| "imported"
| "mirroring"
| "mirrored"
| "failed"
| "syncing"
| "synced"),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
{[
"all",
"imported",
"mirroring",
"mirrored",
"failed",
"syncing",
"synced",
].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Filter controls */}
<div className="flex items-center gap-2">
{/* Membership Role Filter */}
<Select
value={filter.membershipRole || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
membershipRole: value === "all" ? "" : (value as MembershipRole),
}))
}
>
<SelectTrigger className="w-[140px] 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>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh organizations"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* Status Filter */}
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status:
value === "all"
? ""
: (value as
| ""
| "imported"
| "mirroring"
| "mirrored"
| "failed"
| "syncing"
| "synced"),
}))
}
>
<SelectTrigger className="w-[140px] h-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>
<Button
variant="default"
onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh organizations"
className="h-10 w-10"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="default"
onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0}
className="h-10 px-4"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
</div>
</div>
</div>
<OrganizationList
@@ -404,7 +653,9 @@ export function Organization() {
loadingOrgIds={loadingOrgIds}
onMirror={handleMirrorOrg}
onAddOrganization={() => setIsDialogOpen(true)}
onRefresh={() => fetchOrganizations(false)}
onRefresh={async () => {
await fetchOrganizations(false);
}}
/>
<AddOrganizationDialog

View File

@@ -127,9 +127,9 @@ export function OrganizationList({
}, [organizations, filter]);
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) => (
<Skeleton key={i} className="h-[136px] w-full" />
<Skeleton key={i} className="h-[11.25rem] w-full" />
))}
</div>
) : filteredOrganizations.length === 0 ? (
@@ -161,7 +161,7 @@ export function OrganizationList({
)}
</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) => {
const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
@@ -171,20 +171,33 @@ export function OrganizationList({
<Card
key={index}
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"
)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<Building2 className="h-5 w-5 text-muted-foreground" />
<a
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer"
>
{org.name}
</a>
{/* Mobile Layout */}
<div className="flex flex-col gap-3 sm:hidden">
{/* Header with org name and badges */}
<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
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
className="font-medium hover:underline cursor-pointer truncate"
>
{org.name}
</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
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
org.membershipRole === "member"
@@ -195,128 +208,166 @@ export function OrganizationList({
{org.membershipRole}
</span>
</div>
</div>
{/* Destination override section */}
<div className="mt-2">
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
{/* Destination override section */}
<div>
<MirrorDestinationEditor
organizationId={org.id!}
organizationName={org.name!}
currentDestination={org.destinationOrg}
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
isUpdating={isLoading}
/>
</div>
</div>
{/* 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(
"h-3.5 w-3.5",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
{/* Destination override section */}
<div className="mb-4">
<MirrorDestinationEditor
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"}
</span>
</div>
{/* Repository breakdown */}
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-20" />
</div>
) : (
<div className="flex items-center gap-3">
{org.publicRepositoryCount !== undefined && (
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
<span className="text-muted-foreground">
{org.publicRepositoryCount} public
</span>
</div>
)}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
<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
</span>
</div>
)}
</div>
)}
</div>
</div>
<Badge variant={statusBadge.variant} className="ml-2">
{StatusIcon && <StatusIcon className={cn(
"h-3 w-3",
org.status === "mirroring" && "animate-pulse"
)} />}
{statusBadge.label}
</Badge>
</div>
<div className="text-sm text-muted-foreground mb-4">
<div className="flex items-center justify-between">
<span className="font-medium">
{org.repositoryCount}{" "}
{org.repositoryCount === 1 ? "repository" : "repositories"}
</span>
</div>
{/* Always render this section to prevent layout shift */}
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
<>
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-16" />
</>
) : (
<>
{org.publicRepositoryCount !== undefined ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
{org.publicRepositoryCount} public
</span>
) : null}
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
<span className="flex items-center gap-1">
<div className="h-2 w-2 rounded-full bg-orange-500" />
{org.privateRepositoryCount} private
</span>
) : null}
{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 className="flex items-center justify-between">
{/* Mobile Actions */}
<div className="flex flex-col gap-3 sm:hidden">
<div className="flex items-center gap-2">
{org.status === "imported" && (
<Button
size="sm"
size="default"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<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="sm" disabled variant="outline">
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<Button size="default" disabled variant="outline" className="w-full h-10">
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Mirroring...
</Button>
)}
{org.status === "mirrored" && (
<Button size="sm" disabled variant="secondary">
<Check className="h-3 w-3 mr-2" />
<Button size="default" disabled variant="secondary" className="w-full h-10">
<Check className="h-4 w-4 mr-2" />
Mirrored
</Button>
)}
{org.status === "failed" && (
<Button
size="sm"
size="default"
variant="destructive"
onClick={() => org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
className="w-full h-10"
>
{isLoading ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Retrying...
</>
) : (
<>
<AlertCircle className="h-3 w-3 mr-2" />
Retry
<AlertCircle className="h-4 w-4 mr-2" />
Retry Mirror
</>
)}
</Button>
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2 justify-center">
{(() => {
const giteaUrl = getGiteaOrgUrl(org);
@@ -337,34 +388,166 @@ export function OrganizationList({
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<Button variant="outline" size="default" asChild className="flex-1 h-10 min-w-0">
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
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>
</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" />
<span className="text-xs ml-2">Gitea</span>
</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" />
<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
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4 mr-2" />
GitHub
</a>
</Button>
</div>
);
})()}
</div>
</div>
</Card>
);
})}

View File

@@ -60,12 +60,12 @@ export default function AddRepositoryDialog({
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0">
<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" />
</Button>
</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>
<DialogTitle>Add Repository</DialogTitle>
<DialogDescription>

View File

@@ -18,8 +18,18 @@ import {
SelectValue,
} from "../ui/select";
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 {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner";
@@ -71,8 +81,6 @@ export default function Repository() {
)
);
}
console.log("Received new log:", data);
}, []);
// Use the SSE hook
@@ -559,86 +567,353 @@ export default function Repository() {
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 (
<div className="flex flex-col gap-y-8">
{/* Combine search and actions into a single flex row */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
<div className="relative flex-grow min-w-[180px]">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search repositories..."
className="pl-8 h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
<div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Search and filters */}
<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">
<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}
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 Combobox */}
<OwnerCombobox
options={ownerOptions}
value={filter.owner || ""}
onChange={(owner: string) =>
setFilter((prev) => ({ ...prev, owner }))
}
/>
{/* 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 Combobox */}
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
{/* 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>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
{status === "all"
? "All Status"
: status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
>
<RefreshCw className="h-4 w-4" />
</Button>
{/* Context-aware action buttons */}
{selectedRepoIds.size === 0 ? (
{/* 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 mr-2" />
Mirror All
<FlipHorizontal className="h-4 w-4" />
</Button>
) : (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
</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}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Owner Combobox */}
<OwnerCombobox
options={ownerOptions}
value={filter.owner || ""}
onChange={(owner: string) =>
setFilter((prev) => ({ ...prev, owner }))
}
/>
{/* Organization Combobox */}
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
{/* Filter controls in a responsive row */}
<div className="flex flex-row items-center gap-2">
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-[140px] 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>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
className="h-10 w-10 shrink-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Bulk actions on desktop - integrated into the same line */}
<div className="flex items-center gap-2 border-l pl-4">
{selectedRepoIds.size === 0 ? (
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="whitespace-nowrap"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<>
<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">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
@@ -652,44 +927,45 @@ export default function Repository() {
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
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="sm"
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="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
<div className="flex gap-2 flex-wrap">
{availableActions.includes('mirror') && (
<Button
variant="default"
size="sm"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
<span>Mirror </span>({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Sync </span>({selectedRepoIds.size})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="sm"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
)}
</div>
</div>
)}
{!isGitHubConfigured ? (
<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}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
onRefresh={() => fetchRepositories(false)}
onRefresh={async () => {
await fetchRepositories(false);
}}
/>
)}

View File

@@ -33,17 +33,22 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
variant="outline"
role="combobox"
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" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-0">
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandInput placeholder="Search owners..." />
<CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandEmpty>No owners found.</CommandEmpty>
<CommandGroup>
<CommandItem
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")} />
All
All owners
</CommandItem>
{options.map((option) => (
<CommandItem
@@ -86,17 +91,22 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
variant="outline"
role="combobox"
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" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-0">
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
<Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
<CommandInput placeholder="Search organizations..." />
<CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty>
<CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup>
<CommandItem
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")} />
All
All organizations
</CommandItem>
{options.map((option) => (
<CommandItem
@@ -128,4 +138,4 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
</PopoverContent>
</Popover>
);
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
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 type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
@@ -17,6 +17,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InlineDestinationEditor } from "./InlineDestinationEditor";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface RepositoryTableProps {
repositories: Repository[];
@@ -166,338 +168,562 @@ export default function RepositoryTable({
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
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 ? (
<div className="border rounded-md">
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
<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>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
{/* 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-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" />
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
<Skeleton className="h-full w-full" />
Links
</div>
</div>
))}
</div>
) : filteredRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No repositories found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Configure your GitHub connection to start mirroring repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
})
}
>
Clear Filters
</Button>
) : (
<Button asChild>
<a href="/config">Configure GitHub</a>
</Button>
)}
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-[65px] flex items-center justify-between border-b bg-transparent"
>
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Skeleton className="h-4 w-4" />
</div>
<div className="h-full p-3 flex-[2.5]">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-3 w-24 mt-1" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-20" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-20" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-24" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-4 w-16" />
</div>
<div className="h-full p-3 flex-[1]">
<Skeleton className="h-8 w-20" />
</div>
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</div>
))}
</div>
</div>
) : (
<div className="flex flex-col border rounded-md">
{/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
<div>
{hasAnyFilter && (
<div className="mb-4 flex items-center gap-2">
<span className="text-sm text-muted-foreground">
Showing {filteredRepositories.length} of {repositories.length} repositories
</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
organization: "",
owner: "",
})
}
>
Clear filters
</Button>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
</div>
)}
{/* table body wrapper (for a parent in virtualization) */}
<div
ref={tableParentRef}
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = loadingRepoIds.has(repo.id ?? "");
return (
<div
key={index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
</div>
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</div>
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
{repo.status === "failed" && repo.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div>
{/* Actions */}
<div className="h-full p-3 flex items-center justify-start flex-[1]">
<RepoActionButton
repo={{ id: repo.id ?? "", status: repo.status }}
isLoading={isLoading}
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
/>
</div>
{/* Links */}
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
{(() => {
const giteaUrl = getGiteaRepoUrl(repo);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (repo.status === 'imported') {
tooltip = "Repository not yet mirrored to Gitea";
} else if (repo.status === 'failed') {
tooltip = "Repository mirroring failed";
} else if (repo.status === 'mirroring') {
tooltip = "Repository is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea repository not available";
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
{/* Status Bar */}
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
<div className="flex items-center gap-2">
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
<span className="text-sm font-medium text-foreground">
{filteredRepositories.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">
{hasAnyFilter
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
</span>
? "No repositories match the current filters"
: "No repositories found"}
</p>
</div>
) : (
<>
{/* 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>
{/* Center - Live active indicator */}
{isLiveActive && (
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite'
}}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Live active
</span>
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite',
animationDelay: '1s'
}}
/>
{/* Repository cards */}
{filteredRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
)}
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
</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-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
</div>
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
Repository
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Organization
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Last Mirrored
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
Actions
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
Links
</div>
</div>
{/* Table body wrapper (for a parent in virtualization) */}
<div
ref={tableParentRef}
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = loadingRepoIds.has(repo.id ?? "");
return (
<div
key={index}
ref={rowVirtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
}}
data-index={virtualRow.index}
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
aria-label={`Select ${repo.name}`}
/>
</div>
{/* Repository */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
</div>
{repo.isPrivate && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Private
</span>
)}
{repo.isForked && (
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
Fork
</span>
)}
</div>
{/* Owner */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">{repo.owner}</p>
</div>
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</div>
{/* Last Mirrored */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm">
{repo.lastMirrored
? formatDate(new Date(repo.lastMirrored))
: "Never"}
</p>
</div>
{/* Status */}
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
{repo.status === "failed" && repo.errorMessage ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-x-2 cursor-help">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="text-sm">{repo.errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<>
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="text-sm capitalize">{repo.status}</span>
</>
)}
</div>
{/* Actions */}
<div className="h-full p-3 flex items-center justify-start flex-[1]">
<RepoActionButton
repo={{ id: repo.id ?? "", status: repo.status }}
isLoading={isLoading}
onMirror={() => onMirror({ repoId: repo.id ?? "" })}
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
/>
</div>
{/* Links */}
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
{(() => {
const giteaUrl = getGiteaRepoUrl(repo);
// Determine tooltip based on status and configuration
let tooltip: string;
if (!giteaConfig?.url) {
tooltip = "Gitea not configured";
} else if (repo.status === 'imported') {
tooltip = "Repository not yet mirrored to Gitea";
} else if (repo.status === 'failed') {
tooltip = "Repository mirroring failed";
} else if (repo.status === 'mirroring') {
tooltip = "Repository is being mirrored to Gitea";
} else if (giteaUrl) {
tooltip = "View on Gitea";
} else {
tooltip = "Gitea repository not available";
}
return giteaUrl ? (
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title={tooltip}
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" disabled title={tooltip}>
<SiGitea className="h-4 w-4" />
</Button>
);
})()}
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
</div>
);
})}
</div>
</div>
{/* Status Bar */}
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
<div className="flex items-center gap-2">
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
<span className="text-sm font-medium text-foreground">
{hasAnyFilter
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
</span>
</div>
{/* Center - Live active indicator */}
{isLiveActive && (
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite'
}}
/>
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
Live active
</span>
<div
className="h-1 w-1 rounded-full bg-emerald-500"
style={{
animation: 'pulse 2s ease-in-out infinite',
animationDelay: '1s'
}}
/>
</div>
)}
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
</div>
</div>
</>
)}
</div>
);
}
@@ -531,7 +757,7 @@ function RepoActionButton({
disabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
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;
disabled ||= repo.status === "mirroring";
} else {
@@ -558,4 +784,4 @@ function RepoActionButton({
)}
</Button>
);
}
}

View 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,
}

View File

@@ -38,9 +38,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// setIsLoading(true);
try {
const user = await authApi.getCurrentUser();
console.log("User data refreshed:", user);
setUser(user);
} catch (err: any) {
setUser(null);

View File

@@ -64,7 +64,6 @@ export const useSSE = ({
eventSource.onopen = () => {
setConnected(true);
setReconnectCount(0); // Reset reconnect counter on successful connection
console.log(`Connected to SSE for user: ${userId}`);
};
eventSource.onerror = (error) => {

View File

@@ -51,7 +51,6 @@ export function useRepoSync({
const sync = async () => {
try {
console.log("Attempting to sync...");
const response = await fetch("/api/job/schedule-sync-repo", {
method: "POST",
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
const result = await response.json();
console.log("Sync successful:", result);
return result;
} catch (error) {
console.error("Sync failed:", error);

View File

@@ -3,6 +3,7 @@ import {
type RepositoryVisibility,
type RepoStatus,
} from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
import { Octokit } from "@octokit/rest";
import type { Config } from "@/types/config";
import type { Organization, Repository } from "./db/schema";
@@ -22,13 +23,26 @@ export const getOrganizationConfig = async ({
userId: string;
}): Promise<Organization | null> => {
try {
const [orgConfig] = await db
const result = await db
.select()
.from(organizations)
.where(and(eq(organizations.name, orgName), eq(organizations.userId, userId)))
.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) {
console.error(`Error fetching organization config for ${orgName}:`, error);
return null;
@@ -113,7 +127,7 @@ export const getGiteaRepoOwner = ({
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
const mirrorStrategy = config.giteaConfig.mirrorStrategy ||
(config.githubConfig.preserveOrgStructure ? "preserve" : "flat-user");
(config.giteaConfig.preserveOrgStructure ? "preserve" : "flat-user");
switch (mirrorStrategy) {
case "preserve":
@@ -870,8 +884,8 @@ export async function mirrorGitHubOrgToGitea({
});
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
(config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
let giteaOrgId: number;
let targetOrgName: string;

View File

@@ -146,3 +146,13 @@
.dark ::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.6 0 0);
}
/* ===== Animations ===== */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}