Added basic responsive layout

This commit is contained in:
Arunavo Ray
2025-07-07 17:34:54 +05:30
parent 6155e39360
commit 24bd0aefe6
10 changed files with 715 additions and 538 deletions

View File

@@ -560,10 +560,10 @@ export default function Repository() {
const availableActions = getAvailableActions();
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]">
<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"
@@ -594,50 +594,57 @@ export default function Repository() {
}
/>
<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>
{/* 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>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={handleRefresh}
title="Refresh repositories"
className="shrink-0"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{/* Context-aware action buttons */}
{/* Action buttons - separate row on mobile */}
<div className="flex items-center gap-2 flex-wrap">
{selectedRepoIds.size === 0 ? (
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="w-full sm:w-auto"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<div className="flex items-center gap-2">
<>
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
@@ -652,42 +659,44 @@ 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>
<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 className="hidden sm:inline">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>

View File

@@ -33,13 +33,13 @@ 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"
>
{value ? value : placeholder}
<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()}...`} />
<CommandList>
@@ -86,13 +86,13 @@ 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"
>
{value ? value : placeholder}
<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()}...`} />
<CommandList>
@@ -128,4 +128,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,283 +168,89 @@ export default function RepositoryTable({
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
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>
// 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);
{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 text-sm font-medium flex-[2.5]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[1]">
<Skeleton className="h-full w-full" />
</div>
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
<Skeleton className="h-full w-full" />
</div>
</div>
))}
</div>
) : filteredRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">No repositories found</h3>
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
{hasAnyFilter
? "Try adjusting your search or filter criteria."
: "Configure your GitHub connection to start mirroring repositories."}
</p>
{hasAnyFilter ? (
<Button
variant="outline"
onClick={() =>
setFilter({
searchTerm: "",
status: "",
})
}
>
Clear Filters
</Button>
) : (
<Button asChild>
<a href="/config">Configure GitHub</a>
</Button>
)}
</div>
) : (
<div className="flex flex-col border rounded-md">
{/* table header */}
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
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" //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}`}
/>
return (
<Card className="mb-3">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
className="mt-1"
/>
<div className="flex-1 space-y-3">
{/* Repository Info */}
<div>
<h3 className="font-medium text-sm break-all">{repo.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{repo.isPrivate && <Badge variant="secondary" className="text-xs"><Lock className="h-3 w-3 mr-1" />Private</Badge>}
{repo.isForked && <Badge variant="secondary" className="text-xs"><GitFork className="h-3 w-3 mr-1" />Fork</Badge>}
{repo.isStarred && <Badge variant="secondary" className="text-xs"><Star className="h-3 w-3 mr-1" />Starred</Badge>}
</div>
</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>
)}
{/* Owner & Organization */}
<div className="text-xs text-muted-foreground">
<div>Owner: {repo.owner}</div>
{repo.organization && <div>Org: {repo.organization}</div>}
{repo.destinationOrg && <div>Destination: {repo.destinationOrg}</div>}
</div>
{/* Status & Last Mirrored */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
<span className="capitalize">{repo.status}</span>
</div>
<span className="text-muted-foreground">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"}
</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>
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
{(repo.status === "imported" || repo.status === "failed") && (
<Button
size="sm"
variant="default"
onClick={() => repo.id && onMirror({ repoId: repo.id })}
disabled={isLoading}
>
<FlipHorizontal className="h-3 w-3 mr-1" />
Mirror
</Button>
)}
{(repo.status === "mirrored" || repo.status === "synced") && (
<Button
size="sm"
variant="outline"
onClick={() => repo.id && onSync({ repoId: repo.id })}
disabled={isLoading}
>
<RefreshCw className="h-3 w-3 mr-1" />
Sync
</Button>
)}
{repo.status === "failed" && (
<Button
size="sm"
variant="outline"
onClick={() => repo.id && onRetry({ repoId: repo.id })}
disabled={isLoading}
>
<RotateCcw className="h-3 w-3 mr-1" />
Retry
</Button>
)}
{/* Links */}
<div className="flex gap-1 ml-auto">
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<a
href={repo.url}
target="_blank"
@@ -452,110 +260,408 @@ export default function RepositoryTable({
<SiGithub className="h-4 w-4" />
</a>
</Button>
{giteaUrl ? (
<Button variant="ghost" size="icon" className="h-8 w-8" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
title="View on Gitea"
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
) : (
<Button variant="ghost" size="icon" className="h-8 w-8" disabled title="Not mirrored to Gitea">
<SiGitea className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</CardContent>
</Card>
);
};
return isLoading ? (
<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>
{/* 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>
{/* 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]">
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>
{/* 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'
}}
/>
{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>
)}
{hasAnyFilter && (
<span className="text-xs text-muted-foreground">
Filters applied
</span>
)}
))}
</div>
</div>
);
}
) : (
<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>
)}
function RepoActionButton({
repo,
isLoading,
onMirror,
onSync,
onRetry,
}: {
repo: { id: string; status: string };
isLoading: boolean;
onMirror: () => void;
onSync: () => void;
onRetry: () => void;
}) {
let label = "";
let icon = <></>;
let onClick = () => {};
let disabled = isLoading;
if (repo.status === "failed") {
label = "Retry";
icon = <RotateCcw className="h-4 w-4 mr-1" />;
onClick = onRetry;
} else if (["mirrored", "synced", "syncing"].includes(repo.status)) {
label = "Sync";
icon = <RefreshCw className="h-4 w-4 mr-1" />;
onClick = onSync;
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.
onClick = onMirror;
disabled ||= repo.status === "mirroring";
} else {
return null; // unsupported status
}
return (
<Button
variant="ghost"
disabled={disabled}
onClick={onClick}
className="min-w-[80px] justify-start"
>
{isLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-1" />
{label}
</>
{filteredRepositories.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">
{hasAnyFilter
? "No repositories match the current filters"
: "No repositories found"}
</p>
</div>
) : (
<>
{icon}
{label}
{/* Mobile card view */}
<div className="lg:hidden">
{/* Select all checkbox */}
<div className="flex items-center gap-2 mb-3 p-2 bg-muted/50 rounded-md">
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="Select all repositories"
/>
<span className="text-sm font-medium">
Select All ({filteredRepositories.length})
</span>
</div>
{/* Repository cards */}
{filteredRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
{/* Desktop table view */}
<div className="hidden lg:block 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 with virtualization */}
<div
ref={tableParentRef}
className="overflow-auto max-h-[calc(100dvh-25rem)]"
style={{
contain: "strict",
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const repo = filteredRepositories[virtualRow.index];
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
const giteaUrl = getGiteaRepoUrl(repo);
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
className="flex items-center justify-between border-b bg-transparent hover:bg-muted/50 transition-colors"
>
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
/>
</div>
<div className="h-full p-3 flex-[2.5] pr-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{repo.name}</span>
{repo.isPrivate && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Lock className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Private repository</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{repo.isForked && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<GitFork className="h-3 w-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Forked repository</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{repo.isStarred && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Star className="h-3 w-3 text-yellow-500" />
</TooltipTrigger>
<TooltipContent>
<p>Starred repository</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{repo.fullName}
</p>
</div>
<div className="h-full p-3 flex-[1] text-sm">
{repo.owner}
</div>
<div className="h-full p-3 flex-[1] text-sm">
<div className="flex flex-col">
<span>{repo.organization || "-"}</span>
{repo.destinationOrg && repo.id && (
<InlineDestinationEditor
repositoryId={repo.id}
currentDestination={repo.destinationOrg}
onUpdate={handleUpdateDestination}
/>
)}
</div>
</div>
<div className="h-full p-3 flex-[1] text-sm">
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"}
</div>
<div className="h-full p-3 flex-[1] flex items-center">
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${getStatusColor(
repo.status
)}`}
/>
<span className="text-sm capitalize">{repo.status}</span>
</div>
</div>
<div className="h-full p-3 flex-[1] flex items-center gap-1">
{(repo.status === "imported" || repo.status === "failed") && (
<Button
size="sm"
variant="default"
onClick={() => repo.id && onMirror({ repoId: repo.id })}
disabled={isLoading}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror
</Button>
)}
{(repo.status === "mirrored" || repo.status === "synced") && (
<Button
size="sm"
variant="outline"
onClick={() => repo.id && onSync({ repoId: repo.id })}
disabled={isLoading}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync
</Button>
)}
{repo.status === "failed" && (
<Button
size="sm"
variant="outline"
onClick={() => repo.id && onRetry({ repoId: repo.id })}
disabled={isLoading}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
</div>
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a
href={repo.url}
target="_blank"
rel="noopener noreferrer"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{giteaUrl ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<a
href={giteaUrl}
target="_blank"
rel="noopener noreferrer"
>
<SiGitea className="h-4 w-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on Gitea</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" disabled>
<SiGitea className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Not mirrored to Gitea</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</>
)}
</Button>
</div>
);
}
}