mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-01-31 14:51:18 +03:00
Added basic responsive layout
This commit is contained in:
@@ -179,16 +179,16 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return isLoading || !connected ? (
|
return isLoading || !connected ? (
|
||||||
<div className="flex flex-col gap-y-6">
|
<div className="flex flex-col gap-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
<StatusCardSkeleton />
|
<StatusCardSkeleton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-x-6 items-start">
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||||
{/* Repository List Skeleton */}
|
{/* Repository List Skeleton */}
|
||||||
<div className="w-1/2 border rounded-lg p-4">
|
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24" />
|
||||||
@@ -201,7 +201,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity Skeleton */}
|
{/* Recent Activity Skeleton */}
|
||||||
<div className="w-1/2 border rounded-lg p-4">
|
<div className="w-full lg:w-1/2 border rounded-lg p-4">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24" />
|
||||||
@@ -217,24 +217,24 @@ export function Dashboard() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-y-6">
|
<div className="flex flex-col gap-y-6">
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6">
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Total Repositories"
|
title="Repositories"
|
||||||
value={repoCount}
|
value={repoCount}
|
||||||
icon={<GitFork className="h-4 w-4" />}
|
icon={<GitFork className="h-4 w-4" />}
|
||||||
description="Repositories being mirrored"
|
description="Total in mirror queue"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Mirrored"
|
title="Mirrored"
|
||||||
value={mirroredCount}
|
value={mirroredCount}
|
||||||
icon={<FlipHorizontal className="h-4 w-4" />}
|
icon={<FlipHorizontal className="h-4 w-4" />}
|
||||||
description="Successfully mirrored"
|
description="Synced to Gitea"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Organizations"
|
title="Organizations"
|
||||||
value={orgCount}
|
value={orgCount}
|
||||||
icon={<Building2 className="h-4 w-4" />}
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
description="GitHub organizations"
|
description="From GitHub"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
title="Last Sync"
|
title="Last Sync"
|
||||||
@@ -254,12 +254,16 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-x-6 items-start">
|
<div className="flex flex-col lg:flex-row gap-6 items-start">
|
||||||
|
<div className="w-full lg:w-1/2">
|
||||||
<RepositoryList repositories={repositories} />
|
<RepositoryList repositories={repositories} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full lg:w-1/2">
|
||||||
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
{/* the api already sends 10 activities only but slicing in case of realtime updates */}
|
||||||
<RecentActivity activities={activities.slice(0, 10)} />
|
<RecentActivity activities={activities.slice(0, 10)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
|||||||
<a href="/activity">View All</a>
|
<a href="/activity">View All</a>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||||
<div className="flex flex-col divide-y divide-border">
|
<div className="flex flex-col divide-y divide-border">
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No recent activity</p>
|
<p className="text-sm text-muted-foreground">No recent activity</p>
|
||||||
@@ -31,7 +31,7 @@ export function RecentActivity({ activities }: RecentActivityProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex-1 space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">
|
<p className="text-sm font-medium leading-none break-words">
|
||||||
{activity.message}
|
{activity.message}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
<a href="/repositories">View All</a>
|
<a href="/repositories">View All</a>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
<CardContent className="max-h-[300px] sm:max-h-[400px] lg:max-h-[calc(100dvh-22.5rem)] overflow-y-auto">
|
||||||
{repositories.length === 0 ? (
|
{repositories.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
<GitFork className="h-10 w-10 text-muted-foreground mb-4" />
|
||||||
@@ -71,11 +71,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
{repositories.map((repo, index) => (
|
{repositories.map((repo, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center justify-between gap-x-4 py-4"
|
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-x-4 py-4"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center flex-wrap gap-2">
|
||||||
<h4 className="text-sm font-medium">{repo.name}</h4>
|
<h4 className="text-sm font-medium break-all">{repo.name}</h4>
|
||||||
{repo.isPrivate && (
|
{repo.isPrivate && (
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
Private
|
Private
|
||||||
@@ -99,13 +99,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 sm:ml-auto">
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
repo.status
|
repo.status
|
||||||
)}`}
|
)}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs capitalize w-[3rem]">
|
<span className="text-xs capitalize w-[3rem] sm:w-auto">
|
||||||
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
|
||||||
{repo.status}
|
{repo.status}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { toast } from "sonner";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||||
|
import { Menu } from "lucide-react";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
|
||||||
onNavigate?: (page: string) => void;
|
onNavigate?: (page: string) => void;
|
||||||
|
onMenuClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ currentPage, onNavigate }: HeaderProps) {
|
export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
|
||||||
const { user, logout, isLoading } = useAuth();
|
const { user, logout, isLoading } = useAuth();
|
||||||
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
const { isLiveEnabled, toggleLive } = useLiveRefresh();
|
||||||
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
|
||||||
@@ -54,7 +56,19 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b bg-background">
|
<header className="border-b bg-background">
|
||||||
<div className="flex h-[4.5rem] items-center justify-between px-6">
|
<div className="flex h-[4.5rem] items-center justify-between px-4 sm:px-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Hamburger Menu Button - Mobile Only */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="lg:hidden p-2"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Toggle menu</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (currentPage !== 'dashboard') {
|
if (currentPage !== 'dashboard') {
|
||||||
@@ -74,15 +88,16 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
alt="Gitea Mirror Logo"
|
alt="Gitea Mirror Logo"
|
||||||
className="h-6 w-6 hidden dark:block"
|
className="h-6 w-6 hidden dark:block"
|
||||||
/>
|
/>
|
||||||
<span className="text-xl font-bold">Gitea Mirror</span>
|
<span className="text-xl font-bold hidden sm:inline">Gitea Mirror</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2 sm:gap-4">
|
||||||
{showLiveButton && (
|
{showLiveButton && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="sm"
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2 sm:px-4"
|
||||||
onClick={toggleLive}
|
onClick={toggleLive}
|
||||||
title={getTooltip()}
|
title={getTooltip()}
|
||||||
>
|
>
|
||||||
@@ -95,7 +110,7 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
? 'bg-orange-400'
|
? 'bg-orange-400'
|
||||||
: 'bg-gray-500'
|
: 'bg-gray-500'
|
||||||
}`} />
|
}`} />
|
||||||
<span>LIVE</span>
|
<span className="hidden sm:inline">LIVE</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -111,12 +126,12 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
|
|||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Button variant="outline" size="lg" onClick={handleLogout}>
|
<Button variant="outline" size="sm" onClick={handleLogout} className="hidden sm:inline-flex">
|
||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="lg" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<a href="/login">Login</a>
|
<a href="/login">Login</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
|||||||
const { isLoading: configLoading } = useConfigStatus();
|
const { isLoading: configLoading } = useConfigStatus();
|
||||||
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
const [currentPage, setCurrentPage] = useState<AppProps['page']>(initialPage);
|
||||||
const [navigationKey, setNavigationKey] = useState(0);
|
const [navigationKey, setNavigationKey] = useState(0);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
useRepoSync({
|
useRepoSync({
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
@@ -99,10 +100,18 @@ function AppWithProviders({ page: initialPage }: AppProps) {
|
|||||||
return (
|
return (
|
||||||
<NavigationContext.Provider value={{ navigationKey }}>
|
<NavigationContext.Provider value={{ navigationKey }}>
|
||||||
<main className="flex min-h-screen flex-col">
|
<main className="flex min-h-screen flex-col">
|
||||||
<Header currentPage={currentPage} onNavigate={handleNavigation} />
|
<Header
|
||||||
<div className="flex flex-1">
|
currentPage={currentPage}
|
||||||
<Sidebar onNavigate={handleNavigation} />
|
onNavigate={handleNavigation}
|
||||||
<section className="flex-1 p-6 overflow-y-auto h-[calc(100dvh-4.55rem)]">
|
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 relative">
|
||||||
|
<Sidebar
|
||||||
|
onNavigate={handleNavigation}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<section className="flex-1 p-4 sm:p-6 overflow-y-auto h-[calc(100dvh-4.55rem)] w-full lg:w-[calc(100%-16rem)]">
|
||||||
{currentPage === "dashboard" && <Dashboard />}
|
{currentPage === "dashboard" && <Dashboard />}
|
||||||
{currentPage === "repositories" && <Repository />}
|
{currentPage === "repositories" && <Repository />}
|
||||||
{currentPage === "organizations" && <Organization />}
|
{currentPage === "organizations" && <Organization />}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import { VersionInfo } from "./VersionInfo";
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
onNavigate?: (page: string) => void;
|
onNavigate?: (page: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ className, onNavigate }: SidebarProps) {
|
export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
|
||||||
const [currentPath, setCurrentPath] = useState<string>("");
|
const [currentPath, setCurrentPath] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,10 +52,31 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
|
|||||||
|
|
||||||
const pageName = pageMap[href] || 'dashboard';
|
const pageName = pageMap[href] || 'dashboard';
|
||||||
onNavigate?.(pageName);
|
onNavigate?.(pageName);
|
||||||
|
|
||||||
|
// Close sidebar on mobile after navigation
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn("w-64 border-r bg-background", className)}>
|
<>
|
||||||
|
{/* Mobile Backdrop */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"fixed lg:static inset-y-0 left-0 z-50 w-64 bg-background border-r flex flex-col h-full transition-transform duration-200 ease-in-out lg:translate-x-0",
|
||||||
|
isOpen ? "translate-x-0" : "-translate-x-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex flex-col h-full pt-4">
|
<div className="flex flex-col h-full pt-4">
|
||||||
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
|
<nav className="flex flex-col gap-y-1 pl-2 pr-3">
|
||||||
{links.map((link, index) => {
|
{links.map((link, index) => {
|
||||||
@@ -98,5 +121,6 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,10 +560,10 @@ export default function Repository() {
|
|||||||
const availableActions = getAvailableActions();
|
const availableActions = getAvailableActions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-8">
|
<div className="flex flex-col gap-y-4 sm:gap-y-8">
|
||||||
{/* Combine search and actions into a single flex row */}
|
{/* Search and filters */}
|
||||||
<div className="flex flex-row items-center gap-4 w-full flex-wrap">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
|
||||||
<div className="relative flex-grow min-w-[180px]">
|
<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" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -594,6 +594,8 @@ export default function Repository() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Filter controls in a responsive row */}
|
||||||
|
<div className="flex flex-row items-center gap-2 w-full sm:w-auto">
|
||||||
<Select
|
<Select
|
||||||
value={filter.status || "all"}
|
value={filter.status || "all"}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -603,7 +605,7 @@ export default function Repository() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px] h-9 max-h-9">
|
<SelectTrigger className="w-full sm:w-[140px] h-9 max-h-9">
|
||||||
<SelectValue placeholder="All Status" />
|
<SelectValue placeholder="All Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -622,22 +624,27 @@ export default function Repository() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
title="Refresh repositories"
|
title="Refresh repositories"
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
</Button>
|
</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 ? (
|
{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"
|
||||||
>
|
>
|
||||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
Mirror All
|
Mirror All
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{selectedRepoIds.size} selected
|
{selectedRepoIds.size} selected
|
||||||
@@ -652,6 +659,7 @@ export default function Repository() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
{availableActions.includes('mirror') && (
|
{availableActions.includes('mirror') && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -660,7 +668,7 @@ export default function Repository() {
|
|||||||
disabled={loadingRepoIds.size > 0}
|
disabled={loadingRepoIds.size > 0}
|
||||||
>
|
>
|
||||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
Mirror ({selectedRepoIds.size})
|
<span className="hidden sm:inline">Mirror </span>({selectedRepoIds.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -672,7 +680,7 @@ export default function Repository() {
|
|||||||
disabled={loadingRepoIds.size > 0}
|
disabled={loadingRepoIds.size > 0}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
Sync ({selectedRepoIds.size})
|
<span className="hidden sm:inline">Sync </span>({selectedRepoIds.size})
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -688,6 +696,7 @@ export default function Repository() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="w-[160px] justify-between"
|
className="w-full sm:w-[160px] justify-between"
|
||||||
>
|
>
|
||||||
{value ? value : placeholder}
|
{value ? value : placeholder}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[160px] p-0">
|
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
@@ -86,13 +86,13 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = "
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="w-[160px] justify-between"
|
className="w-full sm:w-[160px] justify-between"
|
||||||
>
|
>
|
||||||
{value ? value : placeholder}
|
{value ? value : placeholder}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[160px] p-0">
|
<PopoverContent className="w-[200px] sm:w-[160px] p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
<CommandInput placeholder={`Search ${placeholder.toLowerCase()}...`} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react";
|
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock } from "lucide-react";
|
||||||
import { SiGithub, SiGitea } from "react-icons/si";
|
import { SiGithub, SiGitea } from "react-icons/si";
|
||||||
import type { Repository } from "@/lib/db/schema";
|
import type { Repository } from "@/lib/db/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
interface RepositoryTableProps {
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
@@ -166,8 +168,149 @@ export default function RepositoryTable({
|
|||||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||||
|
|
||||||
|
// Mobile card layout for repository
|
||||||
|
const RepositoryCard = ({ repo }: { repo: Repository }) => {
|
||||||
|
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
|
||||||
|
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
|
||||||
|
const giteaUrl = getGiteaRepoUrl(repo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-3">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="View on GitHub"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return isLoading ? (
|
return isLoading ? (
|
||||||
<div className="border rounded-md">
|
<div className="space-y-3 lg:space-y-0">
|
||||||
|
{/* Mobile skeleton */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Card key={i} className="mb-3">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Skeleton className="h-4 w-4 mt-1" />
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<Skeleton className="h-5 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop skeleton */}
|
||||||
|
<div className="hidden lg:block border rounded-md">
|
||||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Skeleton className="h-4 w-4" />
|
<Skeleton className="h-4 w-4" />
|
||||||
@@ -199,65 +342,94 @@ export default function RepositoryTable({
|
|||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Skeleton className="h-4 w-4" />
|
<Skeleton className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
<div className="h-full p-3 flex-[2.5]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-3 w-24 mt-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-4 w-16" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 flex-[1]">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-8 w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[0.8] text-center">
|
<div className="h-full p-3 flex-[0.8] flex items-center justify-center gap-1">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-8 w-8" />
|
||||||
|
<Skeleton className="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : filteredRepositories.length === 0 ? (
|
</div>
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
) : (
|
||||||
<GitFork className="h-12 w-12 text-muted-foreground mb-4" />
|
<div>
|
||||||
<h3 className="text-lg font-medium">No repositories found</h3>
|
{hasAnyFilter && (
|
||||||
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
{hasAnyFilter
|
<span className="text-sm text-muted-foreground">
|
||||||
? "Try adjusting your search or filter criteria."
|
Showing {filteredRepositories.length} of {repositories.length} repositories
|
||||||
: "Configure your GitHub connection to start mirroring repositories."}
|
</span>
|
||||||
</p>
|
|
||||||
{hasAnyFilter ? (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter({
|
setFilter({
|
||||||
searchTerm: "",
|
searchTerm: "",
|
||||||
status: "",
|
status: "",
|
||||||
|
organization: "",
|
||||||
|
owner: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Clear Filters
|
Clear filters
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<a href="/config">Configure GitHub</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{filteredRepositories.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{hasAnyFilter
|
||||||
|
? "No repositories match the current filters"
|
||||||
|
: "No repositories found"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col border rounded-md">
|
<>
|
||||||
{/* table header */}
|
{/* Mobile card view */}
|
||||||
|
<div className="lg:hidden">
|
||||||
|
{/* 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-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
indeterminate={isPartiallySelected}
|
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
aria-label="Select all repositories"
|
aria-label="Select all repositories"
|
||||||
/>
|
/>
|
||||||
@@ -281,281 +453,215 @@ export default function RepositoryTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* table body wrapper (for a parent in virtualization) */}
|
{/* Table body with virtualization */}
|
||||||
<div
|
<div
|
||||||
ref={tableParentRef}
|
ref={tableParentRef}
|
||||||
className="flex flex-col max-h-[calc(100dvh-276px)] overflow-y-auto" //adjusted height to account for status bar
|
className="overflow-auto max-h-[calc(100dvh-25rem)]"
|
||||||
|
style={{
|
||||||
|
contain: "strict",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: "100%",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const repo = filteredRepositories[virtualRow.index];
|
const repo = filteredRepositories[virtualRow.index];
|
||||||
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
|
||||||
|
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
|
||||||
|
const giteaUrl = getGiteaRepoUrl(repo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={virtualRow.key}
|
||||||
ref={rowVirtualizer.measureElement}
|
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
data-index={virtualRow.index}
|
className="flex items-center justify-between border-b bg-transparent hover:bg-muted/50 transition-colors"
|
||||||
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]">
|
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
|
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
|
||||||
aria-label={`Select ${repo.name}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="h-full p-3 flex-[2.5] pr-2">
|
||||||
{/* Repository */}
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
<span className="font-medium text-sm truncate">{repo.name}</span>
|
||||||
<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 && (
|
{repo.isPrivate && (
|
||||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
|
||||||
Private
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{repo.isForked && (
|
|
||||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
|
||||||
Fork
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Owner */}
|
|
||||||
<div className="h-full p-3 flex items-center flex-[1]">
|
|
||||||
<p className="text-sm">{repo.owner}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Organization */}
|
|
||||||
<div className="h-full p-3 flex items-center flex-[1]">
|
|
||||||
<InlineDestinationEditor
|
|
||||||
repository={repo}
|
|
||||||
giteaConfig={giteaConfig}
|
|
||||||
onUpdate={handleUpdateDestination}
|
|
||||||
isUpdating={loadingRepoIds.has(repo.id ?? "")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Last Mirrored */}
|
|
||||||
<div className="h-full p-3 flex items-center flex-[1]">
|
|
||||||
<p className="text-sm">
|
|
||||||
{repo.lastMirrored
|
|
||||||
? formatDate(new Date(repo.lastMirrored))
|
|
||||||
: "Never"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
|
|
||||||
{repo.status === "failed" && repo.errorMessage ? (
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger>
|
||||||
<div className="flex items-center gap-x-2 cursor-help">
|
<Lock className="h-3 w-3 text-muted-foreground" />
|
||||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
|
||||||
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent>
|
||||||
<p className="text-sm">{repo.errorMessage}</p>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
|
||||||
<span className="text-sm capitalize">{repo.status}</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{/* Actions */}
|
{repo.fullName}
|
||||||
<div className="h-full p-3 flex items-center justify-start flex-[1]">
|
</p>
|
||||||
<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>
|
</div>
|
||||||
|
<div className="h-full p-3 flex-[1] text-sm">
|
||||||
{/* Links */}
|
{repo.owner}
|
||||||
<div className="h-full p-3 flex items-center justify-center gap-x-2 flex-[0.8]">
|
</div>
|
||||||
{(() => {
|
<div className="h-full p-3 flex-[1] text-sm">
|
||||||
const giteaUrl = getGiteaRepoUrl(repo);
|
<div className="flex flex-col">
|
||||||
|
<span>{repo.organization || "-"}</span>
|
||||||
// Determine tooltip based on status and configuration
|
{repo.destinationOrg && repo.id && (
|
||||||
let tooltip: string;
|
<InlineDestinationEditor
|
||||||
if (!giteaConfig?.url) {
|
repositoryId={repo.id}
|
||||||
tooltip = "Gitea not configured";
|
currentDestination={repo.destinationOrg}
|
||||||
} else if (repo.status === 'imported') {
|
onUpdate={handleUpdateDestination}
|
||||||
tooltip = "Repository not yet mirrored to Gitea";
|
/>
|
||||||
} else if (repo.status === 'failed') {
|
)}
|
||||||
tooltip = "Repository mirroring failed";
|
</div>
|
||||||
} else if (repo.status === 'mirroring') {
|
</div>
|
||||||
tooltip = "Repository is being mirrored to Gitea";
|
<div className="h-full p-3 flex-[1] text-sm">
|
||||||
} else if (giteaUrl) {
|
{repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"}
|
||||||
tooltip = "View on Gitea";
|
</div>
|
||||||
} else {
|
<div className="h-full p-3 flex-[1] flex items-center">
|
||||||
tooltip = "Gitea repository not available";
|
<div className="flex items-center gap-2">
|
||||||
}
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||||
return giteaUrl ? (
|
repo.status
|
||||||
<Button variant="ghost" size="icon" asChild>
|
)}`}
|
||||||
<a
|
/>
|
||||||
href={giteaUrl}
|
<span className="text-sm capitalize">{repo.status}</span>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
title={tooltip}
|
<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}
|
||||||
>
|
>
|
||||||
<SiGitea className="h-4 w-4" />
|
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||||
</a>
|
Mirror
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
)}
|
||||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
{(repo.status === "mirrored" || repo.status === "synced") && (
|
||||||
<SiGitea className="h-4 w-4" />
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => repo.id && onSync({ repoId: repo.id })}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Sync
|
||||||
</Button>
|
</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>
|
<Button variant="ghost" size="icon" asChild>
|
||||||
<a
|
<a
|
||||||
href={repo.url}
|
href={repo.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title="View on GitHub"
|
|
||||||
>
|
>
|
||||||
<SiGithub className="h-4 w-4" />
|
<SiGithub className="h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Bar */}
|
|
||||||
<div className="h-[40px] flex items-center justify-between border-t bg-muted/30 px-3 relative">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{hasAnyFilter
|
|
||||||
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
|
|
||||||
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center - Live active indicator */}
|
|
||||||
{isLiveActive && (
|
|
||||||
<div className="flex items-center gap-1.5 absolute left-1/2 transform -translate-x-1/2">
|
|
||||||
<div
|
|
||||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
|
||||||
style={{
|
|
||||||
animation: 'pulse 2s ease-in-out infinite'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">
|
|
||||||
Live active
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
className="h-1 w-1 rounded-full bg-emerald-500"
|
|
||||||
style={{
|
|
||||||
animation: 'pulse 2s ease-in-out infinite',
|
|
||||||
animationDelay: '1s'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasAnyFilter && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Filters applied
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -146,3 +146,13 @@
|
|||||||
.dark ::-webkit-scrollbar-thumb:hover {
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: oklch(0.6 0 0);
|
background-color: oklch(0.6 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Animations ===== */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user