mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-14 07:26:44 +03:00
Updates for mobile
This commit is contained in:
@@ -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";
|
||||
@@ -559,92 +569,298 @@ 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-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">
|
||||
<div className="relative w-full sm:flex-grow sm: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>
|
||||
{/* 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 }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filter controls in a responsive row */}
|
||||
<div className="flex flex-row items-center gap-2 w-full sm:w-auto">
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status: value === "all" ? "" : (value as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm: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>
|
||||
{/* Organization Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Organization
|
||||
{filter.organization && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<OrganizationCombobox
|
||||
options={orgOptions}
|
||||
value={filter.organization || ""}
|
||||
onChange={(organization: string) =>
|
||||
setFilter((prev) => ({ ...prev, organization }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<span className="text-muted-foreground">By</span> Status
|
||||
{filter.status && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={filter.status || "all"}
|
||||
onValueChange={(value) =>
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
status: value === "all" ? "" : (value as RepoStatus),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["all", ...repoStatusEnum.options].map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className="flex items-center gap-2">
|
||||
{status !== "all" && (
|
||||
<span className={`h-2 w-2 rounded-full ${
|
||||
status === "synced" ? "bg-green-500" :
|
||||
status === "failed" ? "bg-red-500" :
|
||||
status === "syncing" ? "bg-blue-500" :
|
||||
"bg-yellow-500"
|
||||
}`} />
|
||||
)}
|
||||
{status === "all"
|
||||
? "All statuses"
|
||||
: status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="gap-2 px-4 pt-2 pb-4 border-t">
|
||||
<DrawerClose asChild>
|
||||
<Button className="w-full" size="sm">
|
||||
Apply Filters
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline" className="w-full" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh repositories"
|
||||
className="shrink-0"
|
||||
className="h-10 w-10 shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</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>
|
||||
|
||||
{/* Action buttons - separate row on mobile */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Action buttons - shows when items are selected or Mirror All on desktop */}
|
||||
<div className={`flex items-center gap-2 flex-wrap ${selectedRepoIds.size === 0 ? 'hidden sm:flex' : ''}`}>
|
||||
{selectedRepoIds.size === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
className="w-full sm:w-auto"
|
||||
className="w-auto"
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -33,17 +33,22 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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-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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user