Updates for mobile

This commit is contained in:
Arunavo Ray
2025-07-07 20:24:09 +05:30
parent 1deaae4d34
commit 6270907e70
12 changed files with 1102 additions and 254 deletions

View File

@@ -51,6 +51,7 @@
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.75", "zod": "^3.25.75",
}, },
"devDependencies": { "devDependencies": {
@@ -1453,6 +1454,8 @@
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],

View File

@@ -78,6 +78,7 @@
"tw-animate-css": "^1.3.5", "tw-animate-css": "^1.3.5",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.75" "zod": "^3.25.75"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState, useRef } from 'react'; import { useCallback, useEffect, useState, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ChevronDown, Download, RefreshCw, Search, Trash2 } from 'lucide-react'; import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -36,6 +36,16 @@ import { toast } from 'sonner';
import { useLiveRefresh } from '@/hooks/useLiveRefresh'; import { useLiveRefresh } from '@/hooks/useLiveRefresh';
import { useConfigStatus } from '@/hooks/useConfigStatus'; import { useConfigStatus } from '@/hooks/useConfigStatus';
import { useNavigation } from '@/components/layout/MainLayout'; import { useNavigation } from '@/components/layout/MainLayout';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
type MirrorJobWithKey = MirrorJob & { _rowKey: string }; type MirrorJobWithKey = MirrorJob & { _rowKey: string };
@@ -343,18 +353,225 @@ export function ActivityLog() {
setShowCleanupDialog(false); setShowCleanupDialog(false);
}; };
// Check if any filters are active
const hasActiveFilters = !!(filter.status || filter.type || filter.name);
const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
setFilter({
searchTerm: filter.searchTerm,
status: '',
type: '',
name: '',
});
};
/* ------------------------------ UI ------------------------------ */ /* ------------------------------ UI ------------------------------ */
return ( return (
<div className='flex flex-col gap-y-4 sm:gap-y-8'> <div className='flex flex-col gap-y-4 sm:gap-y-8'>
<div className='flex w-full flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4'> {/* 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 */} {/* search input */}
<div className='relative w-full sm:flex-1'> <div className="relative flex-1">
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input <input
type='text' type="text"
placeholder='Search activities...' placeholder="Search activities..."
className='h-9 w-full rounded-md border border-input bg-background px-3 py-1 pl-8 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm} value={filter.searchTerm}
onChange={(e) => onChange={(e) =>
setFilter((prev) => ({ setFilter((prev) => ({
@@ -365,8 +582,8 @@ export function ActivityLog() {
/> />
</div> </div>
{/* Filter controls row */} {/* Filter controls */}
<div className='flex flex-row items-center gap-2 w-full sm:w-auto'> <div className="flex items-center gap-2">
{/* status select */} {/* status select */}
<Select <Select
value={filter.status || 'all'} value={filter.status || 'all'}
@@ -377,59 +594,74 @@ export function ActivityLog() {
})) }))
} }
> >
<SelectTrigger className='h-9 w-full sm:w-[140px] max-h-9'> <SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder='All Status' /> <SelectValue placeholder="All statuses" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{['all', ...repoStatusEnum.options].map((s) => ( {['all', ...repoStatusEnum.options].map((s) => (
<SelectItem key={s} value={s}> <SelectItem key={s} value={s}>
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)} <span className="flex items-center gap-2">
{s !== 'all' && (
<span className={`h-2 w-2 rounded-full ${
s === 'synced' ? 'bg-green-500' :
s === 'failed' ? 'bg-red-500' :
s === 'syncing' ? 'bg-blue-500' :
'bg-yellow-500'
}`} />
)}
{s === 'all' ? 'All statuses' : s[0].toUpperCase() + s.slice(1)}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{/* type select - hidden on mobile */} {/* type select */}
<Select <Select
value={filter.type || 'all'} value={filter.type || 'all'}
onValueChange={(v) => onValueChange={(v) =>
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v })) setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
} }
> >
<SelectTrigger className='h-9 w-[140px] max-h-9 hidden sm:flex'> <SelectTrigger className="w-[140px] h-10">
<SelectValue placeholder='All Types' /> <SelectValue placeholder="All types" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{['all', 'repository', 'organization'].map((t) => ( {['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}> <SelectItem key={t} value={t}>
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)} <span className="flex items-center gap-2">
{t !== 'all' && (
<span className={`h-2 w-2 rounded-full ${
t === 'repository' ? 'bg-blue-500' : 'bg-purple-500'
}`} />
)}
{t === 'all' ? 'All types' : t[0].toUpperCase() + t.slice(1)}
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* repo/org name combobox - hidden on mobile */} {/* repo/org name combobox */}
<div className='hidden sm:block'> <ActivityNameCombobox
<ActivityNameCombobox activities={activities}
activities={activities} value={filter.name || ''}
value={filter.name || ''} onChange={(name) => setFilter((p) => ({ ...p, name }))}
onChange={(name) => setFilter((p) => ({ ...p, name }))} />
/>
</div>
{/* Action buttons row */} {/* Action buttons */}
<div className='flex items-center gap-2 ml-auto'> <div className="flex items-center gap-2 ml-auto">
{/* export dropdown - text hidden on mobile */} {/* export dropdown */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='flex items-center gap-1'> <Button variant="outline" size="sm" className="h-10">
<Download className='h-4 w-4' /> <Download className="h-4 w-4 mr-2" />
<span className='hidden sm:inline'>Export</span> Export
<ChevronDown className='h-4 w-4 hidden sm:inline' /> <ChevronDown className="h-4 w-4 ml-1" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={exportAsCSV}> <DropdownMenuItem onClick={exportAsCSV}>
Export as CSV Export as CSV
</DropdownMenuItem> </DropdownMenuItem>
@@ -443,11 +675,11 @@ export function ActivityLog() {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton onClick={() => fetchActivities(false)}
title="Refresh activity log" title="Refresh activity log"
className='h-8 w-8 sm:h-9 sm:w-9' className="h-10 w-10"
> >
<RefreshCw className='h-4 w-4' /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
{/* cleanup all activities */} {/* cleanup all activities */}
@@ -456,9 +688,9 @@ export function ActivityLog() {
size="icon" size="icon"
onClick={handleCleanupClick} onClick={handleCleanupClick}
title="Delete all activities" title="Delete all activities"
className="text-destructive hover:text-destructive h-8 w-8 sm:h-9 sm:w-9" className="text-destructive hover:text-destructive h-10 w-10"
> >
<Trash2 className='h-4 w-4' /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -495,6 +727,26 @@ export function ActivityLog() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Mobile FAB for Export - only visible on mobile */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="fixed bottom-4 right-4 rounded-full h-12 w-12 shadow-lg p-0 z-10 sm:hidden"
variant="default"
>
<Download className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="mb-2">
<DropdownMenuItem onClick={exportAsCSV}>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={exportAsJSON}>
Export as JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
); );
} }

View File

@@ -41,9 +41,14 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-[180px] justify-between" className="w-full sm:w-[180px] justify-between h-10"
> >
{value ? value : "All Names"} <span className={cn(
"truncate",
!value && "text-muted-foreground"
)}>
{value || "All names"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -62,7 +67,7 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} /> <Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All Names All names
</CommandItem> </CommandItem>
{names.map((name) => ( {names.map((name) => (
<CommandItem <CommandItem

View File

@@ -98,6 +98,25 @@ export function AutomationSettings({
<CardTitle className="text-lg font-semibold flex items-center gap-2"> <CardTitle className="text-lg font-semibold flex items-center gap-2">
<Zap className="h-5 w-5" /> <Zap className="h-5 w-5" />
Automation & Maintenance Automation & Maintenance
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button className="ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 bg-muted hover:bg-muted/80 transition-colors">
<Info className="h-3 w-3" />
<span className="sr-only">Background operations info</span>
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="space-y-2">
<p className="font-medium">Background Operations</p>
<p className="text-xs">
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
Choose intervals that match your workflow and repository update frequency.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -311,21 +330,6 @@ export function AutomationSettings({
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 p-4 bg-blue-50/50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-900">
<div className="flex gap-3">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Background Operations
</p>
<p className="text-xs text-blue-800 dark:text-blue-200/80">
These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance.
Choose intervals that match your workflow and repository update frequency.
</p>
</div>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

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

View File

@@ -303,7 +303,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
<span>Override Options</span> <span>Override Options</span>
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent side="left" align="start" className="w-[380px]"> <HoverCardContent side="bottom" align="start" className="w-[380px]">
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4> <h4 className="font-medium text-sm mb-1.5">Fine-tune Your Mirror Destinations</h4>

View File

@@ -125,42 +125,24 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
{isLoading ? ( {isLoading ? (
<AuthButtonsSkeleton /> <AuthButtonsSkeleton />
) : user ? ( ) : user ? (
<> <DropdownMenu>
{/* Desktop: Show avatar and logout button */} <DropdownMenuTrigger asChild>
<div className="hidden sm:flex sm:items-center sm:gap-4"> <Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
<Avatar> <Avatar className="h-full w-full">
<AvatarImage src="" alt="@shadcn" /> <AvatarImage src="" alt="@shadcn" />
<AvatarFallback> <AvatarFallback>
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<Button variant="outline" size="sm" onClick={handleLogout}>
Logout
</Button> </Button>
</div> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/* Mobile: Avatar with dropdown */} <DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
<div className="sm:hidden"> <LogOut className="h-4 w-4 mr-2" />
<DropdownMenu> Logout
<DropdownMenuTrigger asChild> </DropdownMenuItem>
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0"> </DropdownMenuContent>
<Avatar className="h-full w-full"> </DropdownMenu>
<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>
</div>
</>
) : ( ) : (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<a href="/login">Login</a> <a href="/login">Login</a>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal } from "lucide-react"; import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react";
import type { MirrorJob, Organization } from "@/lib/db/schema"; import type { MirrorJob, Organization } from "@/lib/db/schema";
import { OrganizationList } from "./OrganizationsList"; import { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog"; import AddOrganizationDialog from "./AddOrganizationDialog";
@@ -27,6 +27,16 @@ import { toast } from "sonner";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout"; import { useNavigation } from "@/components/layout/MainLayout";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
export function Organization() { export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -290,120 +300,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 ( return (
<div className="flex flex-col gap-y-4 sm:gap-y-8"> <div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Search and filters */} {/* Search and filters */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
<div className="relative w-full sm:flex-grow"> {/* Mobile: Search bar with filter button */}
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2 w-full sm:hidden">
<input <div className="relative flex-grow">
type="text" <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
placeholder="Search Organizations..." <input
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" type="text"
value={filter.searchTerm} placeholder="Search organizations..."
onChange={(e) => 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"
setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) value={filter.searchTerm}
} onChange={(e) =>
/> setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
</div> }
/>
</div>
{/* Filter controls */} {/* Mobile Filter Drawer */}
<div className="flex items-center gap-2 w-full sm:w-auto"> <Drawer>
{/* Membership Role Filter */} <DrawerTrigger asChild>
<Select <Button
value={filter.membershipRole || "all"} variant="outline"
onValueChange={(value) => size="icon"
setFilter((prev) => ({ className="relative h-10 w-10 shrink-0"
...prev, >
membershipRole: value === "all" ? "" : (value as MembershipRole), <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}
<SelectTrigger className="w-full sm:w-[140px] h-9 max-h-9"> </span>
<SelectValue placeholder="All Roles" /> )}
</SelectTrigger> </Button>
<SelectContent> </DrawerTrigger>
{["all", ...membershipRoleEnum.options].map((role) => ( <DrawerContent className="max-h-[85vh]">
<SelectItem key={role} value={role}> <DrawerHeader className="text-left">
{role === "all" <DrawerTitle className="text-lg font-semibold">Filter Organizations</DrawerTitle>
? "All Roles" <DrawerDescription className="text-sm text-muted-foreground">
: role Narrow down your organization list
.replace(/_/g, " ") </DrawerDescription>
.replace(/\b\w/g, (c) => c.toUpperCase())} </DrawerHeader>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status Filter */} <div className="px-4 py-6 space-y-6 overflow-y-auto">
<Select {/* Active filters summary */}
value={filter.status || "all"} {hasActiveFilters && (
onValueChange={(value) => <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
setFilter((prev) => ({ <span className="text-sm font-medium">
...prev, {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
status: </span>
value === "all" <Button
? "" variant="ghost"
: (value as size="sm"
| "" onClick={clearFilters}
| "imported" className="h-7 px-2 text-xs"
| "mirroring" >
| "mirrored" Clear all
| "failed" </Button>
| "syncing" </div>
| "synced"), )}
}))
} {/* Role Filter */}
> <div className="space-y-2">
<SelectTrigger className="w-full sm:w-[140px] h-9 max-h-9"> <label className="text-sm font-medium flex items-center gap-2">
<SelectValue placeholder="All Statuses" /> <span className="text-muted-foreground">By</span> Role
</SelectTrigger> {filter.membershipRole && (
<SelectContent> <span className="ml-auto text-xs text-muted-foreground">
{[ {filter.membershipRole
"all", .replace(/_/g, " ")
"imported", .replace(/\b\w/g, (c) => c.toUpperCase())}
"mirroring", </span>
"mirrored", )}
"failed", </label>
"syncing", <Select
"synced", value={filter.membershipRole || "all"}
].map((status) => ( onValueChange={(value) =>
<SelectItem key={status} value={status}> setFilter((prev) => ({
{status === "all" ...prev,
? "All Statuses" membershipRole: value === "all" ? "" : (value as MembershipRole),
: status.charAt(0).toUpperCase() + status.slice(1)} }))
</SelectItem> }
))} >
</SelectContent> <SelectTrigger className="w-full h-10">
</Select> <SelectValue placeholder="All roles" />
</div> </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>
{/* Action buttons */}
<div className="flex items-center gap-2 ml-auto">
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleRefresh} onClick={handleRefresh}
title="Refresh organizations" title="Refresh organizations"
className="h-8 w-8 sm:h-9 sm:w-9" className="h-10 w-10 shrink-0"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="default" variant="default"
size="sm" size="icon"
onClick={handleMirrorAllOrgs} onClick={handleMirrorAllOrgs}
disabled={isLoading || loadingOrgIds.size > 0} disabled={isLoading || loadingOrgIds.size > 0}
className="sm:size-default" title="Mirror all organizations"
className="h-10 w-10 shrink-0"
> >
<FlipHorizontal className="h-4 w-4 mr-2" /> <FlipHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">Mirror All</span>
<span className="sm:hidden">Mirror</span>
</Button> </Button>
</div> </div>
{/* Desktop: Original layout */}
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
<div className="relative flex-grow">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search organizations..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
onChange={(e) =>
setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
}
/>
</div>
{/* Filter controls */}
<div className="flex items-center gap-2">
{/* Membership Role Filter */}
<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>
{/* 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>
{/* 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> </div>
<OrganizationList <OrganizationList

View File

@@ -18,8 +18,18 @@ import {
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react"; import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { useSSE } from "@/hooks/useSEE"; import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams"; import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -559,92 +569,298 @@ export default function Repository() {
const availableActions = getAvailableActions(); const availableActions = getAvailableActions();
// Check if any filters are active
const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length;
// Clear all filters
const clearFilters = () => {
setFilter({
searchTerm: filter.searchTerm,
status: "",
organization: "",
owner: "",
});
};
return ( return (
<div className="flex flex-col gap-y-4 sm:gap-y-8"> <div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Search and filters */} {/* Search and filters */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
<div className="relative w-full sm:flex-grow sm:min-w-[180px]"> {/* Mobile: Search bar with filter button */}
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <div className="flex items-center gap-2 w-full sm:hidden">
<input <div className="relative flex-grow">
type="text" <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
placeholder="Search repositories..." <input
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" type="text"
value={filter.searchTerm} placeholder="Search repositories..."
onChange={(e) => 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"
setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) value={filter.searchTerm}
} onChange={(e) =>
/> setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
</div> }
/>
</div>
{/* Owner Combobox */} {/* Mobile Filter Drawer */}
<OwnerCombobox <Drawer>
options={ownerOptions} <DrawerTrigger asChild>
value={filter.owner || ""} <Button
onChange={(owner: string) => variant="outline"
setFilter((prev) => ({ ...prev, owner })) 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>
{/* Organization Combobox */} <div className="px-4 py-6 space-y-6 overflow-y-auto">
<OrganizationCombobox {/* Active filters summary */}
options={orgOptions} {hasActiveFilters && (
value={filter.organization || ""} <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
onChange={(organization: string) => <span className="text-sm font-medium">
setFilter((prev) => ({ ...prev, organization })) {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>
)}
{/* Filter controls in a responsive row */} {/* Owner Filter */}
<div className="flex flex-row items-center gap-2 w-full sm:w-auto"> <div className="space-y-2">
<Select <label className="text-sm font-medium flex items-center gap-2">
value={filter.status || "all"} <span className="text-muted-foreground">By</span> Owner
onValueChange={(value) => {filter.owner && (
setFilter((prev) => ({ <span className="ml-auto text-xs text-muted-foreground">
...prev, Selected
status: value === "all" ? "" : (value as RepoStatus), </span>
})) )}
} </label>
> <OwnerCombobox
<SelectTrigger className="w-full sm:w-[140px] h-9 max-h-9"> options={ownerOptions}
<SelectValue placeholder="All Status" /> value={filter.owner || ""}
</SelectTrigger> onChange={(owner: string) =>
<SelectContent> setFilter((prev) => ({ ...prev, owner }))
{["all", ...repoStatusEnum.options].map((status) => ( }
<SelectItem key={status} value={status}> />
{status === "all" </div>
? "All Status"
: status.charAt(0).toUpperCase() + status.slice(1)} {/* Organization Filter */}
</SelectItem> <div className="space-y-2">
))} <label className="text-sm font-medium flex items-center gap-2">
</SelectContent> <span className="text-muted-foreground">By</span> Organization
</Select> {filter.organization && (
<span className="ml-auto text-xs text-muted-foreground">
Selected
</span>
)}
</label>
<OrganizationCombobox
options={orgOptions}
value={filter.organization || ""}
onChange={(organization: string) =>
setFilter((prev) => ({ ...prev, organization }))
}
/>
</div>
{/* Status Filter */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<span className="text-muted-foreground">By</span> Status
{filter.status && (
<span className="ml-auto text-xs text-muted-foreground">
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
</span>
)}
</label>
<Select
value={filter.status || "all"}
onValueChange={(value) =>
setFilter((prev) => ({
...prev,
status: value === "all" ? "" : (value as RepoStatus),
}))
}
>
<SelectTrigger className="w-full h-10">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{["all", ...repoStatusEnum.options].map((status) => (
<SelectItem key={status} value={status}>
<span className="flex items-center gap-2">
{status !== "all" && (
<span className={`h-2 w-2 rounded-full ${
status === "synced" ? "bg-green-500" :
status === "failed" ? "bg-red-500" :
status === "syncing" ? "bg-blue-500" :
"bg-yellow-500"
}`} />
)}
{status === "all"
? "All statuses"
: status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
<DrawerClose asChild>
<Button className="w-full" size="sm">
Apply Filters
</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="outline" className="w-full" size="sm">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleRefresh} onClick={handleRefresh}
title="Refresh repositories" title="Refresh repositories"
className="shrink-0" className="h-10 w-10 shrink-0"
> >
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
{selectedRepoIds.size === 0 && (
<Button
variant="default"
size="icon"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
title="Mirror all repositories"
className="h-10 w-10 shrink-0"
>
<FlipHorizontal className="h-4 w-4" />
</Button>
)}
</div>
{/* Desktop: Original layout */}
<div className="hidden sm:flex sm:flex-row sm:items-center sm:gap-4 sm:w-full">
<div className="relative flex-grow min-w-[180px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search repositories..."
className="pl-10 pr-3 h-10 w-full rounded-md border border-input bg-background text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={filter.searchTerm}
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>
</div> </div>
</div> </div>
{/* Action buttons - separate row on mobile */} {/* Action buttons - shows when items are selected or Mirror All on desktop */}
<div className="flex items-center gap-2 flex-wrap"> <div className={`flex items-center gap-2 flex-wrap ${selectedRepoIds.size === 0 ? 'hidden sm:flex' : ''}`}>
{selectedRepoIds.size === 0 ? ( {selectedRepoIds.size === 0 ? (
<Button <Button
variant="default" variant="default"
onClick={handleMirrorAllRepos} onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0} disabled={isInitialLoading || loadingRepoIds.size > 0}
className="w-full sm:w-auto" className="w-auto"
> >
<FlipHorizontal className="h-4 w-4 mr-2" /> <FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All Mirror All
</Button> </Button>
) : ( ) : (
<> <>
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md"> <div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{selectedRepoIds.size} selected {selectedRepoIds.size} selected

View File

@@ -33,17 +33,22 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full sm:w-[160px] justify-between" className="w-full sm:w-[160px] justify-between h-10"
> >
{value ? value : placeholder} <span className={cn(
"truncate",
!value && "text-muted-foreground"
)}>
{value || "All owners"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[200px] sm:w-[160px] p-0"> <PopoverContent className="w-[200px] sm:w-[160px] p-0">
<Command> <Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} /> <CommandInput placeholder="Search owners..." />
<CommandList> <CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty> <CommandEmpty>No owners found.</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
key="all" key="all"
@@ -54,7 +59,7 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} /> <Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All All owners
</CommandItem> </CommandItem>
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem
@@ -86,17 +91,22 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full sm:w-[160px] justify-between" className="w-full sm:w-[160px] justify-between h-10"
> >
{value ? value : placeholder} <span className={cn(
"truncate",
!value && "text-muted-foreground"
)}>
{value || "All organizations"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[200px] sm:w-[160px] p-0"> <PopoverContent className="w-[200px] sm:w-[160px] p-0">
<Command> <Command>
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} /> <CommandInput placeholder="Search organizations..." />
<CommandList> <CommandList>
<CommandEmpty>No {placeholder.toLowerCase()} found.</CommandEmpty> <CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
key="all" key="all"
@@ -107,7 +117,7 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
}} }}
> >
<Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} /> <Check className={cn("mr-2 h-4 w-4", value === "" ? "opacity-100" : "opacity-0")} />
All All organizations
</CommandItem> </CommandItem>
{options.map((option) => ( {options.map((option) => (
<CommandItem <CommandItem

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