-
+
{activity.message}
diff --git a/src/components/dashboard/RepositoryList.tsx b/src/components/dashboard/RepositoryList.tsx
index d2fc9ea..ecc5ee6 100644
--- a/src/components/dashboard/RepositoryList.tsx
+++ b/src/components/dashboard/RepositoryList.tsx
@@ -54,7 +54,7 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
View All
-
+
{repositories.length === 0 ? (
@@ -71,11 +71,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
{repositories.map((repo, index) => (
-
-
{repo.name}
+
+
{repo.name}
{repo.isPrivate && (
Private
@@ -99,13 +99,13 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
-
+
-
+
{/* setting the minimum width to 3rem corresponding to the largest status (mirrored) so that all are left alligned */}
{repo.status}
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index d002e84..96e2ef2 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -7,13 +7,21 @@ import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus";
+import { Menu, LogOut } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
onNavigate?: (page: string) => void;
+ onMenuClick: () => void;
}
-export function Header({ currentPage, onNavigate }: HeaderProps) {
+export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
const { user, logout, isLoading } = useAuth();
const { isLiveEnabled, toggleLive } = useLiveRefresh();
const { isFullyConfigured, isLoading: configLoading } = useConfigStatus();
@@ -54,39 +62,52 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
return (
-
-
{
- if (currentPage !== 'dashboard') {
- window.history.pushState({}, '', '/');
- onNavigate?.('dashboard');
- }
- }}
- className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
- >
-
-
- Gitea Mirror
-
+
+
+ {/* Hamburger Menu Button - Mobile Only */}
+
+
+ Toggle menu
+
+
+
{
+ if (currentPage !== 'dashboard') {
+ window.history.pushState({}, '', '/');
+ onNavigate?.('dashboard');
+ }
+ }}
+ className="flex items-center gap-2 py-1 hover:opacity-80 transition-opacity"
+ >
+
+
+ Gitea Mirror
+
+
-
+
{showLiveButton && (
-
- LIVE
+ LIVE
)}
@@ -104,19 +125,26 @@ export function Header({ currentPage, onNavigate }: HeaderProps) {
{isLoading ? (
) : user ? (
- <>
-
-
-
- {user.username.charAt(0).toUpperCase()}
-
-
-
- Logout
-
- >
+
+
+
+
+
+
+ {user.username.charAt(0).toUpperCase()}
+
+
+
+
+
+
+
+ Logout
+
+
+
) : (
-
+
Login
)}
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index 4ed6607..9341a54 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -44,6 +44,7 @@ function AppWithProviders({ page: initialPage }: AppProps) {
const { isLoading: configLoading } = useConfigStatus();
const [currentPage, setCurrentPage] = useState(initialPage);
const [navigationKey, setNavigationKey] = useState(0);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
useRepoSync({
userId: user?.id,
@@ -99,10 +100,18 @@ function AppWithProviders({ page: initialPage }: AppProps) {
return (
-
-
-
-
+ setSidebarOpen(!sidebarOpen)}
+ />
+
+
setSidebarOpen(false)}
+ />
+
{currentPage === "dashboard" && }
{currentPage === "repositories" && }
{currentPage === "organizations" && }
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index 8ac6ac9..220e4fd 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -7,9 +7,11 @@ import { VersionInfo } from "./VersionInfo";
interface SidebarProps {
className?: string;
onNavigate?: (page: string) => void;
+ isOpen: boolean;
+ onClose: () => void;
}
-export function Sidebar({ className, onNavigate }: SidebarProps) {
+export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps) {
const [currentPath, setCurrentPath] = useState("");
useEffect(() => {
@@ -50,53 +52,77 @@ export function Sidebar({ className, onNavigate }: SidebarProps) {
const pageName = pageMap[href] || 'dashboard';
onNavigate?.(pageName);
+
+ // Close sidebar on mobile after navigation
+ if (window.innerWidth < 1024) {
+ onClose();
+ }
};
return (
-
-
-
- {links.map((link, index) => {
- const isActive = currentPath === link.href;
- const Icon = link.icon;
+ <>
+ {/* Mobile Backdrop */}
+ {isOpen && (
+
+ )}
+
+ {/* Sidebar */}
+
+
+
+ {links.map((link, index) => {
+ const isActive = currentPath === link.href;
+ const Icon = link.icon;
- return (
- handleNavigation(link.href, e)}
- className={cn(
- "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left",
- isActive
- ? "bg-primary text-primary-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
- )}
+ return (
+ handleNavigation(link.href, e)}
+ className={cn(
+ "flex items-center gap-3 rounded-md px-3 py-3 lg:py-2 text-sm lg:text-sm font-medium transition-colors w-full text-left",
+ isActive
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
+ )}
+ >
+
+ {link.label}
+
+ );
+ })}
+
+
+
+
+
-
+
+ >
);
}
diff --git a/src/components/organizations/AddOrganizationDialog.tsx b/src/components/organizations/AddOrganizationDialog.tsx
index f2e50c3..8d85ab1 100644
--- a/src/components/organizations/AddOrganizationDialog.tsx
+++ b/src/components/organizations/AddOrganizationDialog.tsx
@@ -63,12 +63,12 @@ export default function AddOrganizationDialog({
return (
-
+
-
+
Add Organization
diff --git a/src/components/organizations/MirrorDestinationEditor.tsx b/src/components/organizations/MirrorDestinationEditor.tsx
index 0a610c0..e214d83 100644
--- a/src/components/organizations/MirrorDestinationEditor.tsx
+++ b/src/components/organizations/MirrorDestinationEditor.tsx
@@ -69,19 +69,19 @@ export function MirrorDestinationEditor({
};
return (
-
-
-
-
{organizationName}
-
+
+
+
+
{organizationName}
+
{effectiveDestination}
{hasOverride && (
-
+
custom
)}
@@ -92,11 +92,11 @@ export function MirrorDestinationEditor({
-
+
diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx
index 4c3c123..575d553 100644
--- a/src/components/organizations/Organization.tsx
+++ b/src/components/organizations/Organization.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
-import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
+import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react";
import type { MirrorJob, Organization } from "@/lib/db/schema";
import { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog";
@@ -27,6 +27,16 @@ import { toast } from "sonner";
import { useConfigStatus } from "@/hooks/useConfigStatus";
import { useNavigation } from "@/components/layout/MainLayout";
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer";
export function Organization() {
const [organizations, setOrganizations] = useState([]);
@@ -290,110 +300,351 @@ export function Organization() {
}
};
+ // Check if any filters are active
+ const hasActiveFilters = !!(filter.membershipRole || filter.status);
+ const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length;
+ // Clear all filters
+ const clearFilters = () => {
+ setFilter({
+ searchTerm: filter.searchTerm,
+ membershipRole: "",
+ status: "",
+ });
+ };
return (
-
- {/* Combine search and actions into a single flex row */}
-
-
-
-
- setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
- }
- />
+
+ {/* Search and filters */}
+
+ {/* Mobile: Search bar with filter button */}
+
+
+
+
+ setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
+ }
+ />
+
+
+ {/* Mobile Filter Drawer */}
+
+
+
+
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+
+
+ Filter Organizations
+
+ Narrow down your organization list
+
+
+
+
+ {/* Active filters summary */}
+ {hasActiveFilters && (
+
+
+ {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
+
+
+ Clear all
+
+
+ )}
+
+ {/* Role Filter */}
+
+
+ By Role
+ {filter.membershipRole && (
+
+ {filter.membershipRole
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase())}
+
+ )}
+
+
+ setFilter((prev) => ({
+ ...prev,
+ membershipRole: value === "all" ? "" : (value as MembershipRole),
+ }))
+ }
+ >
+
+
+
+
+ {["all", ...membershipRoleEnum.options].map((role) => (
+
+
+ {role !== "all" && (
+
+ )}
+ {role === "all"
+ ? "All roles"
+ : role
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase())}
+
+
+ ))}
+
+
+
+
+ {/* Status Filter */}
+
+
+ By Status
+ {filter.status && (
+
+ {filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
+
+ )}
+
+
+ setFilter((prev) => ({
+ ...prev,
+ status:
+ value === "all"
+ ? ""
+ : (value as
+ | ""
+ | "imported"
+ | "mirroring"
+ | "mirrored"
+ | "failed"
+ | "syncing"
+ | "synced"),
+ }))
+ }
+ >
+
+
+
+
+ {[
+ "all",
+ "imported",
+ "mirroring",
+ "mirrored",
+ "failed",
+ "syncing",
+ "synced",
+ ].map((status) => (
+
+
+ {status !== "all" && (
+
+ )}
+ {status === "all"
+ ? "All statuses"
+ : status.charAt(0).toUpperCase() + status.slice(1)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Apply Filters
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
0}
+ title="Mirror all organizations"
+ className="h-10 w-10 shrink-0"
+ >
+
+
- {/* Membership Role Filter */}
-
- setFilter((prev) => ({
- ...prev,
- membershipRole: value === "all" ? "" : (value as MembershipRole),
- }))
- }
- >
-
-
-
-
- {["all", ...membershipRoleEnum.options].map((role) => (
-
- {role === "all"
- ? "All Roles"
- : role
- .replace(/_/g, " ")
- .replace(/\b\w/g, (c) => c.toUpperCase())}
-
- ))}
-
-
+ {/* Desktop: Original layout */}
+
+
+
+
+ setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
+ }
+ />
+
- {/* Status Filter */}
-
- setFilter((prev) => ({
- ...prev,
- status:
- value === "all"
- ? ""
- : (value as
- | ""
- | "imported"
- | "mirroring"
- | "mirrored"
- | "failed"
- | "syncing"
- | "synced"),
- }))
- }
- >
-
-
-
-
- {[
- "all",
- "imported",
- "mirroring",
- "mirrored",
- "failed",
- "syncing",
- "synced",
- ].map((status) => (
-
- {status === "all"
- ? "All Statuses"
- : status.charAt(0).toUpperCase() + status.slice(1)}
-
- ))}
-
-
+ {/* Filter controls */}
+
+ {/* Membership Role Filter */}
+
+ setFilter((prev) => ({
+ ...prev,
+ membershipRole: value === "all" ? "" : (value as MembershipRole),
+ }))
+ }
+ >
+
+
+
+
+ {["all", ...membershipRoleEnum.options].map((role) => (
+
+
+ {role !== "all" && (
+
+ )}
+ {role === "all"
+ ? "All roles"
+ : role
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase())}
+
+
+ ))}
+
+
-
-
-
+ {/* Status Filter */}
+
+ setFilter((prev) => ({
+ ...prev,
+ status:
+ value === "all"
+ ? ""
+ : (value as
+ | ""
+ | "imported"
+ | "mirroring"
+ | "mirrored"
+ | "failed"
+ | "syncing"
+ | "synced"),
+ }))
+ }
+ >
+
+
+
+
+ {[
+ "all",
+ "imported",
+ "mirroring",
+ "mirrored",
+ "failed",
+ "syncing",
+ "synced",
+ ].map((status) => (
+
+
+ {status !== "all" && (
+
+ )}
+ {status === "all"
+ ? "All statuses"
+ : status.charAt(0).toUpperCase() + status.slice(1)}
+
+
+ ))}
+
+
+
-
0}
- >
-
- Mirror All
-
+ {/* Action buttons */}
+
+
+
+
+
+ 0}
+ className="h-10 px-4"
+ >
+
+ Mirror All
+
+
+
+
{Array.from({ length: 5 }).map((_, i) => (
-
+
))}
) : filteredOrganizations.length === 0 ? (
@@ -161,7 +161,7 @@ export function OrganizationList({
)}
) : (
-
+
{filteredOrganizations.map((org, index) => {
const isLoading = loadingOrgIds.has(org.id ?? "");
const statusBadge = getStatusBadge(org.status);
@@ -171,20 +171,33 @@ export function OrganizationList({
-
-
-
-
-
- {org.name}
-
+ {/* Mobile Layout */}
+
+ {/* Header with org name and badges */}
+
+
+
+
+ {StatusIcon && }
+ {statusBadge.label}
+
+
+
+
- {/* Destination override section */}
-
-
handleUpdateDestination(org.id!, newDestination)}
- isUpdating={isLoading}
- />
+ {/* Destination override section */}
+
+ handleUpdateDestination(org.id!, newDestination)}
+ isUpdating={isLoading}
+ />
+
+
+
+ {/* Desktop Layout */}
+
+ {/* Header with org icon, name, role badge and status */}
+
+
+
+ {/* Status badge */}
+
+ {StatusIcon && }
+ {statusBadge.label}
+
+
+
+ {/* Destination override section */}
+
+ handleUpdateDestination(org.id!, newDestination)}
+ isUpdating={isLoading}
+ />
+
+
+ {/* Repository statistics */}
+
+
+
+ {org.repositoryCount}
+
+ {org.repositoryCount === 1 ? "repository" : "repositories"}
+
+
+
+ {/* Repository breakdown */}
+ {isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
+
+
+
+
+ ) : (
+
+ {org.publicRepositoryCount !== undefined && (
+
+
+
+ {org.publicRepositoryCount} public
+
+
+ )}
+ {org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
+
+
+
+ {org.privateRepositoryCount} private
+
+
+ )}
+
+ )}
-
- {StatusIcon && }
- {statusBadge.label}
-
-
-
-
- {org.repositoryCount}{" "}
- {org.repositoryCount === 1 ? "repository" : "repositories"}
-
-
- {/* Always render this section to prevent layout shift */}
-
- {isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
- <>
-
-
- >
- ) : (
- <>
- {org.publicRepositoryCount !== undefined ? (
-
-
- {org.publicRepositoryCount} public
-
- ) : null}
- {org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
-
-
- {org.privateRepositoryCount} private
-
- ) : null}
- {org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
-
-
- {org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
-
- ) : null}
- {/* Show a placeholder if no counts are available to maintain height */}
- {org.publicRepositoryCount === undefined &&
- org.privateRepositoryCount === undefined &&
- org.forkRepositoryCount === undefined && (
-
Loading counts...
- )}
- >
- )}
-
-
-
-
+ {/* Mobile Actions */}
+
{org.status === "imported" && (
org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
+ className="w-full h-10"
>
{isLoading ? (
<>
-
+
Starting...
>
) : (
- "Mirror"
+ <>
+
+ Mirror Organization
+ >
)}
)}
{org.status === "mirroring" && (
-
-
+
+
Mirroring...
)}
{org.status === "mirrored" && (
-
-
+
+
Mirrored
)}
{org.status === "failed" && (
org.id && onMirror({ orgId: org.id })}
disabled={isLoading}
+ className="w-full h-10"
>
{isLoading ? (
<>
-
+
Retrying...
>
) : (
<>
-
- Retry
+
+ Retry Mirror
>
)}
)}
-
-
+
+
{(() => {
const giteaUrl = getGiteaOrgUrl(org);
@@ -337,34 +388,166 @@ export function OrganizationList({
}
return giteaUrl ? (
-
+
-
+
+ Gitea
) : (
-
+
+ Gitea
);
})()}
-
+
-
+
+ GitHub
+
+ {/* Desktop Actions */}
+
+
+ {org.status === "imported" && (
+
org.id && onMirror({ orgId: org.id })}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+
+ Starting mirror...
+ >
+ ) : (
+ <>
+
+ Mirror Organization
+ >
+ )}
+
+ )}
+
+ {org.status === "mirroring" && (
+
+
+ Mirroring in progress...
+
+ )}
+
+ {org.status === "mirrored" && (
+
+
+ Successfully mirrored
+
+ )}
+
+ {org.status === "failed" && (
+
org.id && onMirror({ orgId: org.id })}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+
+ Retrying...
+ >
+ ) : (
+ <>
+
+ Retry Mirror
+ >
+ )}
+
+ )}
+
+
+
+ {(() => {
+ const giteaUrl = getGiteaOrgUrl(org);
+
+ // Determine tooltip based on status and configuration
+ let tooltip: string;
+ if (!giteaConfig?.url) {
+ tooltip = "Gitea not configured";
+ } else if (org.status === 'imported') {
+ tooltip = "Organization not yet mirrored to Gitea";
+ } else if (org.status === 'failed') {
+ tooltip = "Organization mirroring failed";
+ } else if (org.status === 'mirroring') {
+ tooltip = "Organization is being mirrored to Gitea";
+ } else if (giteaUrl) {
+ tooltip = "View on Gitea";
+ } else {
+ tooltip = "Gitea organization not available";
+ }
+
+ return (
+
+ );
+ })()}
+
+
);
})}
diff --git a/src/components/repositories/AddRepositoryDialog.tsx b/src/components/repositories/AddRepositoryDialog.tsx
index 73e572b..f44ec5a 100644
--- a/src/components/repositories/AddRepositoryDialog.tsx
+++ b/src/components/repositories/AddRepositoryDialog.tsx
@@ -60,12 +60,12 @@ export default function AddRepositoryDialog({
return (
-
+
-
+
Add Repository
diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx
index 01b2202..22e746c 100644
--- a/src/components/repositories/Repository.tsx
+++ b/src/components/repositories/Repository.tsx
@@ -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,86 +569,353 @@ export default function Repository() {
const availableActions = getAvailableActions();
+ // Check if any filters are active
+ const hasActiveFilters = !!(filter.owner || filter.organization || filter.status);
+ const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length;
+
+ // Clear all filters
+ const clearFilters = () => {
+ setFilter({
+ searchTerm: filter.searchTerm,
+ status: "",
+ organization: "",
+ owner: "",
+ });
+ };
+
return (
-
- {/* Combine search and actions into a single flex row */}
-
-
-
-
- setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
- }
- />
-
+
+ {/* Search and filters */}
+
+ {/* Mobile: Search bar with filter button */}
+
+
+
+
+ setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
+ }
+ />
+
+
+ {/* Mobile Filter Drawer */}
+
+
+
+
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+
+
+ Filter Repositories
+
+ Narrow down your repository list
+
+
+
+
+ {/* Active filters summary */}
+ {hasActiveFilters && (
+
+
+ {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active
+
+
+ Clear all
+
+
+ )}
- {/* Owner Combobox */}
-
- setFilter((prev) => ({ ...prev, owner }))
- }
- />
+ {/* Owner Filter */}
+
+
+ By Owner
+ {filter.owner && (
+
+ Selected
+
+ )}
+
+
+ setFilter((prev) => ({ ...prev, owner }))
+ }
+ />
+
- {/* Organization Combobox */}
-
- setFilter((prev) => ({ ...prev, organization }))
- }
- />
+ {/* Organization Filter */}
+
+
+ By Organization
+ {filter.organization && (
+
+ Selected
+
+ )}
+
+
+ setFilter((prev) => ({ ...prev, organization }))
+ }
+ />
+
-
- setFilter((prev) => ({
- ...prev,
- status: value === "all" ? "" : (value as RepoStatus),
- }))
- }
- >
-
-
-
-
- {["all", ...repoStatusEnum.options].map((status) => (
-
- {status === "all"
- ? "All Status"
- : status.charAt(0).toUpperCase() + status.slice(1)}
-
- ))}
-
-
-
-
-
-
-
- {/* Context-aware action buttons */}
- {selectedRepoIds.size === 0 ? (
+ {/* Status Filter */}
+
+
+ By Status
+ {filter.status && (
+
+ {filter.status.charAt(0).toUpperCase() + filter.status.slice(1)}
+
+ )}
+
+
+ setFilter((prev) => ({
+ ...prev,
+ status: value === "all" ? "" : (value as RepoStatus),
+ }))
+ }
+ >
+
+
+
+
+ {["all", ...repoStatusEnum.options].map((status) => (
+
+
+ {status !== "all" && (
+
+ )}
+ {status === "all"
+ ? "All statuses"
+ : status.charAt(0).toUpperCase() + status.slice(1)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Apply Filters
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
+
0}
+ title="Mirror all repositories"
+ className="h-10 w-10 shrink-0"
>
-
- Mirror All
+
- ) : (
-
-
+
+
+ {/* Desktop: Original layout */}
+
+
+
+
+ setFilter((prev) => ({ ...prev, searchTerm: e.target.value }))
+ }
+ />
+
+
+ {/* Owner Combobox */}
+
+ setFilter((prev) => ({ ...prev, owner }))
+ }
+ />
+
+ {/* Organization Combobox */}
+
+ setFilter((prev) => ({ ...prev, organization }))
+ }
+ />
+
+ {/* Filter controls in a responsive row */}
+
+
+ setFilter((prev) => ({
+ ...prev,
+ status: value === "all" ? "" : (value as RepoStatus),
+ }))
+ }
+ >
+
+
+
+
+ {["all", ...repoStatusEnum.options].map((status) => (
+
+
+ {status !== "all" && (
+
+ )}
+ {status === "all"
+ ? "All statuses"
+ : status.charAt(0).toUpperCase() + status.slice(1)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* Bulk actions on desktop - integrated into the same line */}
+
+ {selectedRepoIds.size === 0 ? (
+
0}
+ className="whitespace-nowrap"
+ >
+
+ Mirror All
+
+ ) : (
+ <>
+
+
+ {selectedRepoIds.size} selected
+
+ setSelectedRepoIds(new Set())}
+ >
+
+
+
+
+ {availableActions.includes('mirror') && (
+
0}
+ >
+
+ Mirror ({selectedRepoIds.size})
+
+ )}
+
+ {availableActions.includes('sync') && (
+
0}
+ >
+
+ Sync ({selectedRepoIds.size})
+
+ )}
+
+ {availableActions.includes('retry') && (
+
0}
+ >
+
+ Retry
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* Action buttons for mobile - only show when items are selected */}
+ {selectedRepoIds.size > 0 && (
+
+
{selectedRepoIds.size} selected
@@ -652,44 +929,45 @@ export default function Repository() {
- {availableActions.includes('mirror') && (
-
0}
- >
-
- Mirror ({selectedRepoIds.size})
-
- )}
-
- {availableActions.includes('sync') && (
-
0}
- >
-
- Sync ({selectedRepoIds.size})
-
- )}
-
- {availableActions.includes('retry') && (
-
0}
- >
-
- Retry
-
- )}
+
+ {availableActions.includes('mirror') && (
+ 0}
+ >
+
+ Mirror ({selectedRepoIds.size})
+
+ )}
+
+ {availableActions.includes('sync') && (
+ 0}
+ >
+
+ Sync ({selectedRepoIds.size})
+
+ )}
+
+ {availableActions.includes('retry') && (
+ 0}
+ >
+
+ Retry
+
+ )}
- )}
-
+
+ )}
{!isGitHubConfigured ? (
@@ -721,7 +999,9 @@ export default function Repository() {
loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
- onRefresh={() => fetchRepositories(false)}
+ onRefresh={async () => {
+ await fetchRepositories(false);
+ }}
/>
)}
diff --git a/src/components/repositories/RepositoryComboboxes.tsx b/src/components/repositories/RepositoryComboboxes.tsx
index f77c11a..c581e54 100644
--- a/src/components/repositories/RepositoryComboboxes.tsx
+++ b/src/components/repositories/RepositoryComboboxes.tsx
@@ -33,17 +33,22 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner"
variant="outline"
role="combobox"
aria-expanded={open}
- className="w-[160px] justify-between"
+ className="w-full sm:w-[160px] justify-between h-10"
>
- {value ? value : placeholder}
+
+ {value || "All owners"}
+
-
+
-
+
- No {placeholder.toLowerCase()} found.
+ No owners found.
- All
+ All owners
{options.map((option) => (
- {value ? value : placeholder}
+
+ {value || "All organizations"}
+
-
+
-
+
- No {placeholder.toLowerCase()} found.
+ No organizations found.
- All
+ All organizations
{options.map((option) => (
repo.id && selectedRepoIds.has(repo.id));
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
+ // Mobile card layout for repository
+ const RepositoryCard = ({ repo }: { repo: Repository }) => {
+ const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
+ const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
+ const giteaUrl = getGiteaRepoUrl(repo);
+
+ return (
+
+
+
+ {/* Header with checkbox and repo name */}
+
+
repo.id && handleSelectRepo(repo.id, checked as boolean)}
+ className="mt-1 h-5 w-5"
+ aria-label={`Select ${repo.name}`}
+ />
+
+
{repo.name}
+
+ {repo.isPrivate && Private }
+ {repo.isForked && Fork }
+ {repo.isStarred && Starred }
+
+
+
+
+ {/* Repository details */}
+
+ {/* Owner & Organization */}
+
+
+ Owner:
+ {repo.owner}
+
+ {repo.organization && (
+
+ Org:
+ {repo.organization}
+
+ )}
+ {repo.destinationOrg && (
+
+ Dest:
+ {repo.destinationOrg}
+
+ )}
+
+
+ {/* Status & Last Mirrored */}
+
+
+
+ {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
+
+
+
+
+ {/* Actions */}
+
+ {/* Primary action button */}
+ {(repo.status === "imported" || repo.status === "failed") && (
+
repo.id && onMirror({ repoId: repo.id })}
+ disabled={isLoading}
+ className="w-full h-10"
+ >
+ {isLoading ? (
+ <>
+
+ Mirroring...
+ >
+ ) : (
+ <>
+
+ Mirror Repository
+ >
+ )}
+
+ )}
+ {(repo.status === "mirrored" || repo.status === "synced") && (
+
repo.id && onSync({ repoId: repo.id })}
+ disabled={isLoading}
+ className="w-full h-10"
+ >
+ {isLoading ? (
+ <>
+
+ Syncing...
+ >
+ ) : (
+ <>
+
+ Sync Repository
+ >
+ )}
+
+ )}
+ {repo.status === "failed" && (
+
repo.id && onRetry({ repoId: repo.id })}
+ disabled={isLoading}
+ className="w-full h-10"
+ >
+ {isLoading ? (
+ <>
+
+ Retrying...
+ >
+ ) : (
+ <>
+
+ Retry Mirror
+ >
+ )}
+
+ )}
+
+ {/* External links */}
+
+
+
+
+
+ );
+ };
+
return isLoading ? (
-
-
-
-
-
-
- Repository
-
-
Owner
-
- Organization
-
-
- Last Mirrored
-
-
Status
-
- Actions
-
-
- Links
-
+
+ {/* Mobile skeleton */}
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+ ))}
- {Array.from({ length: 5 }).map((_, i) => (
-
+ {/* Desktop skeleton */}
+
+
-
+ Repository
+
+
Owner
+
+ Organization
-
+ Last Mirrored
+
Status
-
-
-
-
-
-
-
-
-
-
+ Actions
-
+ Links
- ))}
-
- ) : filteredRepositories.length === 0 ? (
-
-
-
No repositories found
-
- {hasAnyFilter
- ? "Try adjusting your search or filter criteria."
- : "Configure your GitHub connection to start mirroring repositories."}
-
- {hasAnyFilter ? (
-
- setFilter({
- searchTerm: "",
- status: "",
- })
- }
- >
- Clear Filters
-
- ) : (
-
- Configure GitHub
-
- )}
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
) : (
-
- {/* table header */}
-
-
-
+
+ {hasAnyFilter && (
+
+
+ Showing {filteredRepositories.length} of {repositories.length} repositories
+
+
+ setFilter({
+ searchTerm: "",
+ status: "",
+ organization: "",
+ owner: "",
+ })
+ }
+ >
+ Clear filters
+
-
- Repository
-
-
Owner
-
- Organization
-
-
- Last Mirrored
-
-
Status
-
- Actions
-
-
- Links
-
-
+ )}
- {/* table body wrapper (for a parent in virtualization) */}
-
-
- {rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
- const repo = filteredRepositories[virtualRow.index];
- const isLoading = loadingRepoIds.has(repo.id ?? "");
-
- return (
-
- {/* Checkbox */}
-
- repo.id && handleSelectRepo(repo.id, !!checked)}
- aria-label={`Select ${repo.name}`}
- />
-
-
- {/* Repository */}
-
-
-
-
- {repo.name}
- {repo.isStarred && (
-
- )}
-
-
- {repo.fullName}
-
-
- {repo.isPrivate && (
-
- Private
-
- )}
- {repo.isForked && (
-
- Fork
-
- )}
-
-
- {/* Owner */}
-
-
- {/* Organization */}
-
-
-
-
- {/* Last Mirrored */}
-
-
- {repo.lastMirrored
- ? formatDate(new Date(repo.lastMirrored))
- : "Never"}
-
-
-
- {/* Status */}
-
- {repo.status === "failed" && repo.errorMessage ? (
-
-
-
-
-
-
- {repo.errorMessage}
-
-
-
- ) : (
- <>
-
-
{repo.status}
- >
- )}
-
-
- {/* Actions */}
-
- onMirror({ repoId: repo.id ?? "" })}
- onSync={() => onSync({ repoId: repo.id ?? "" })}
- onRetry={() => onRetry({ repoId: repo.id ?? "" })}
- />
-
-
- {/* Links */}
-
- {(() => {
- 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 ? (
-
-
-
-
-
- ) : (
-
-
-
- );
- })()}
-
-
-
-
-
-
-
- );
- })}
-
-
-
- {/* Status Bar */}
-
-
-
-
+ {filteredRepositories.length === 0 ? (
+
+
{hasAnyFilter
- ? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
- : `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
-
+ ? "No repositories match the current filters"
+ : "No repositories found"}
+
+ ) : (
+ <>
+ {/* Mobile card view */}
+
+ {/* Select all checkbox */}
+
+
+
+ Select All ({filteredRepositories.length})
+
+
- {/* Center - Live active indicator */}
- {isLiveActive && (
-
-
-
- Live active
-
-
+ {/* Repository cards */}
+ {filteredRepositories.map((repo) => (
+
+ ))}
- )}
- {hasAnyFilter && (
-
- Filters applied
-
- )}
-
+ {/* Desktop table view */}
+
+ {/* Table header */}
+
+
+
+
+
+ Repository
+
+
Owner
+
+ Organization
+
+
+ Last Mirrored
+
+
Status
+
+ Actions
+
+
+ Links
+
+
+
+ {/* Table body wrapper (for a parent in virtualization) */}
+
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
+ const repo = filteredRepositories[virtualRow.index];
+ const isLoading = loadingRepoIds.has(repo.id ?? "");
+
+ return (
+
+ {/* Checkbox */}
+
+ repo.id && handleSelectRepo(repo.id, !!checked)}
+ aria-label={`Select ${repo.name}`}
+ />
+
+
+ {/* Repository */}
+
+
+
+
+ {repo.name}
+ {repo.isStarred && (
+
+ )}
+
+
+ {repo.fullName}
+
+
+ {repo.isPrivate && (
+
+ Private
+
+ )}
+ {repo.isForked && (
+
+ Fork
+
+ )}
+
+ {/* Owner */}
+
+
+ {/* Organization */}
+
+
+
+
+ {/* Last Mirrored */}
+
+
+ {repo.lastMirrored
+ ? formatDate(new Date(repo.lastMirrored))
+ : "Never"}
+
+
+
+ {/* Status */}
+
+ {repo.status === "failed" && repo.errorMessage ? (
+
+
+
+
+
+
+ {repo.errorMessage}
+
+
+
+ ) : (
+ <>
+
+
{repo.status}
+ >
+ )}
+
+ {/* Actions */}
+
+ onMirror({ repoId: repo.id ?? "" })}
+ onSync={() => onSync({ repoId: repo.id ?? "" })}
+ onRetry={() => onRetry({ repoId: repo.id ?? "" })}
+ />
+
+ {/* Links */}
+
+ {(() => {
+ 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 ? (
+
+
+
+
+
+ ) : (
+
+
+
+ );
+ })()}
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {/* Status Bar */}
+
+
+
+
+ {hasAnyFilter
+ ? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
+ : `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
+
+
+
+ {/* Center - Live active indicator */}
+ {isLiveActive && (
+
+ )}
+
+ {hasAnyFilter && (
+
+ Filters applied
+
+ )}
+
+
+ >
+ )}
);
}
@@ -531,7 +757,7 @@ function RepoActionButton({
disabled ||= repo.status === "syncing";
} else if (["imported", "mirroring"].includes(repo.status)) {
label = "Mirror";
- icon =
; // Don't change this icon to GitFork.
+ icon =
;
onClick = onMirror;
disabled ||= repo.status === "mirroring";
} else {
@@ -558,4 +784,4 @@ function RepoActionButton({
)}
);
-}
+}
\ No newline at end of file
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..869955f
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,133 @@
+import * as React from "react"
+import { Drawer as DrawerPrimitive } from "vaul"
+
+import { cn } from "@/lib/utils"
+
+function Drawer({
+ ...props
+}: React.ComponentProps
) {
+ return
+}
+
+function DrawerTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DrawerOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DrawerContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+ )
+}
+
+function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DrawerTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DrawerDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Drawer,
+ DrawerPortal,
+ DrawerOverlay,
+ DrawerTrigger,
+ DrawerClose,
+ DrawerContent,
+ DrawerHeader,
+ DrawerFooter,
+ DrawerTitle,
+ DrawerDescription,
+}
diff --git a/src/styles/global.css b/src/styles/global.css
index b95ba51..91bd818 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -146,3 +146,13 @@
.dark ::-webkit-scrollbar-thumb:hover {
background-color: oklch(0.6 0 0);
}
+
+/* ===== Animations ===== */
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}