diff --git a/src/components/activity/ActivityList.tsx b/src/components/activity/ActivityList.tsx index f433436..64741fb 100644 --- a/src/components/activity/ActivityList.tsx +++ b/src/components/activity/ActivityList.tsx @@ -3,11 +3,17 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import type { MirrorJob } from '@/lib/db/schema'; import Fuse from 'fuse.js'; import { Button } from '../ui/button'; -import { RefreshCw } from 'lucide-react'; +import { RefreshCw, Check, X, Loader2, Import } from 'lucide-react'; import { Card } from '../ui/card'; import { formatDate, getStatusColor } from '@/lib/utils'; import { Skeleton } from '../ui/skeleton'; import type { FilterParams } from '@/types/filter'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../ui/tooltip'; type MirrorJobWithKey = MirrorJob & { _rowKey: string }; @@ -73,7 +79,7 @@ export default function ActivityList({ count: filteredActivities.length, getScrollElement: () => parentRef.current, estimateSize: (idx) => - expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120, + expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 100, overscan: 5, measureElement: (el) => el.getBoundingClientRect().height + 8, }); @@ -155,8 +161,8 @@ export default function ActivityList({ }} className='border-b px-4 pt-4' > -
-
+
+
-
-
-

{activity.message}

-

+

+
+
+ {/* Mobile: Show simplified status-based message */} +
+

+ {activity.status === 'synced' ? ( + <> + + Sync successful + + ) : activity.status === 'mirrored' ? ( + <> + + Mirror successful + + ) : activity.status === 'failed' ? ( + <> + + Operation failed + + ) : activity.status === 'syncing' ? ( + <> + + Syncing in progress + + ) : activity.status === 'mirroring' ? ( + <> + + Mirroring in progress + + ) : activity.status === 'imported' ? ( + <> + + Imported + + ) : ( + {activity.message} + )} +

+
+ {/* Desktop: Show status with icon and full message in tooltip */} +
+ + + +

+ {activity.status === 'synced' ? ( + <> + + Sync successful + + ) : activity.status === 'mirrored' ? ( + <> + + Mirror successful + + ) : activity.status === 'failed' ? ( + <> + + Operation failed + + ) : activity.status === 'syncing' ? ( + <> + + Syncing in progress + + ) : activity.status === 'mirroring' ? ( + <> + + Mirroring in progress + + ) : activity.status === 'imported' ? ( + <> + + Imported + + ) : ( + {activity.message} + )} +

+
+ +

{activity.message}

+
+
+
+
+
+

{formatDate(activity.timestamp)}

- {activity.repositoryName && ( -

- Repository: {activity.repositoryName} -

- )} - - {activity.organizationName && ( -

- Organization: {activity.organizationName} -

- )} +
+ {activity.repositoryName && ( +

+ Repo: {activity.repositoryName} +

+ )} + {activity.organizationName && ( +

+ Org: {activity.organizationName} +

+ )} +
{activity.details && (
@@ -199,7 +292,7 @@ export default function ActivityList({ }) } > - {isExpanded ? 'Hide Details' : 'Show Details'} + {isExpanded ? 'Hide Details' : activity.status === 'failed' ? 'Show Error Details' : 'Show Details'} {isExpanded && ( 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/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx index 21efed0..0ff8f82 100644 --- a/src/components/organizations/OrganizationsList.tsx +++ b/src/components/organizations/OrganizationsList.tsx @@ -127,9 +127,9 @@ export function OrganizationList({ }, [organizations, filter]); return isLoading ? ( -
+
{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 */} +
+
+
+
+ + {org.name} + + + {org.membershipRole} + +
+
+
+ + {/* 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.status === "mirroring" && ( - )} {org.status === "mirrored" && ( - )} {org.status === "failed" && ( )}
- -
+ +
{(() => { const giteaUrl = getGiteaOrgUrl(org); @@ -337,34 +388,166 @@ export function OrganizationList({ } return giteaUrl ? ( - ) : ( - ); })()} -
+ + {/* Desktop Actions */} +
+
+ {org.status === "imported" && ( + + )} + + {org.status === "mirroring" && ( + + )} + + {org.status === "mirrored" && ( + + )} + + {org.status === "failed" && ( + + )} +
+ +
+ {(() => { + 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/Repository.tsx b/src/components/repositories/Repository.tsx index 53912a9..22e746c 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -752,18 +752,16 @@ export default function Repository() { - {selectedRepoIds.size === 0 && ( - - )} +
{/* Desktop: Original layout */} @@ -844,24 +842,80 @@ export default function Repository() {
+ + {/* Bulk actions on desktop - integrated into the same line */} +
+ {selectedRepoIds.size === 0 ? ( + + ) : ( + <> +
+ + {selectedRepoIds.size} selected + + +
+ + {availableActions.includes('mirror') && ( + + )} + + {availableActions.includes('sync') && ( + + )} + + {availableActions.includes('retry') && ( + + )} + + )} +
- {/* Action buttons - shows when items are selected or Mirror All on desktop */} -
- {selectedRepoIds.size === 0 ? ( - - ) : ( - <> -
+ {/* Action buttons for mobile - only show when items are selected */} + {selectedRepoIds.size > 0 && ( +
+
{selectedRepoIds.size} selected @@ -877,44 +931,43 @@ export default function Repository() {
{availableActions.includes('mirror') && ( - - )} - - {availableActions.includes('sync') && ( - - )} - - {availableActions.includes('retry') && ( - - )} -
- - )} -
+ + )} + + {availableActions.includes('sync') && ( + + )} + + {availableActions.includes('retry') && ( + + )} +
+
+ )} {!isGitHubConfigured ? (
@@ -946,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/RepositoryTable.tsx b/src/components/repositories/RepositoryTable.tsx index 9abfe67..8b810bc 100644 --- a/src/components/repositories/RepositoryTable.tsx +++ b/src/components/repositories/RepositoryTable.tsx @@ -177,106 +177,159 @@ export default function RepositoryTable({ return ( -
- repo.id && handleSelectRepo(repo.id, checked as boolean)} - className="mt-1" - /> -
- {/* Repository Info */} -
-

{repo.name}

+
+ {/* 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} + {repo.isPrivate && Private} + {repo.isForked && Fork} + {repo.isStarred && Starred}
+
+ {/* Repository details */} +
{/* Owner & Organization */} -
-
Owner: {repo.owner}
- {repo.organization &&
Org: {repo.organization}
} - {repo.destinationOrg &&
Destination: {repo.destinationOrg}
} +
+
+ Owner: + {repo.owner} +
+ {repo.organization && ( +
+ Org: + {repo.organization} +
+ )} + {repo.destinationOrg && ( +
+ Dest: + {repo.destinationOrg} +
+ )}
{/* Status & Last Mirrored */} -
+
-
- {repo.status} +
+ {repo.status}
- - {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"} + + {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"}
+
- {/* Actions */} -
- {(repo.status === "imported" || repo.status === "failed") && ( - + )} + {(repo.status === "mirrored" || repo.status === "synced") && ( + + )} + {repo.status === "failed" && ( + + )} + + {/* External links */} +
@@ -404,13 +457,14 @@ export default function RepositoryTable({ ) : ( <> {/* Mobile card view */} -
+
{/* Select all checkbox */} -
+
Select All ({filteredRepositories.length}) @@ -424,7 +478,7 @@ export default function RepositoryTable({
{/* Desktop table view */} -
+
{/* Table header */}
@@ -453,215 +507,281 @@ export default function RepositoryTable({
- {/* Table body with virtualization */} + {/* Table body wrapper (for a parent in virtualization) */}
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { + {rowVirtualizer.getVirtualItems().map((virtualRow, index) => { 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); + const isLoading = loadingRepoIds.has(repo.id ?? ""); return (
+ {/* Checkbox */}
repo.id && handleSelectRepo(repo.id, checked as boolean)} + checked={repo.id ? selectedRepoIds.has(repo.id) : false} + onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)} + aria-label={`Select ${repo.name}`} />
-
-
- {repo.name} - {repo.isPrivate && ( - - - - - - -

Private repository

-
-
-
- )} - {repo.isForked && ( - - - - - - -

Forked repository

-
-
-
- )} - {repo.isStarred && ( - - - - - - -

Starred repository

-
-
-
- )} + + {/* Repository */} +
+ +
+
+ {repo.name} + {repo.isStarred && ( + + )} +
+
+ {repo.fullName} +
-

- {repo.fullName} + {repo.isPrivate && ( + + Private + + )} + {repo.isForked && ( + + Fork + + )} +

+ {/* Owner */} +
+

{repo.owner}

+
+ + {/* Organization */} +
+ +
+ + {/* Last Mirrored */} +
+

+ {repo.lastMirrored + ? formatDate(new Date(repo.lastMirrored)) + : "Never"}

-
- {repo.owner} -
-
-
- {repo.organization || "-"} - {repo.destinationOrg && repo.id && ( - - )} -
-
-
- {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"} -
-
-
-
- {repo.status} -
-
-
- {(repo.status === "imported" || repo.status === "failed") && ( - - )} - {(repo.status === "mirrored" || repo.status === "synced") && ( - - )} - {repo.status === "failed" && ( - - )} -
-
- - - - - - -

View on GitHub

-
-
-
- {giteaUrl ? ( + {/* Status */} +
+ {repo.status === "failed" && repo.errorMessage ? ( - +
+
+ {repo.status} +
- -

View on Gitea

+ +

{repo.errorMessage}

) : ( - - - - - - -

Not mirrored to Gitea

-
-
-
+ <> +
+ {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 && ( +
+
+ + Live active + +
+
+ )} + + {hasAnyFilter && ( + + Filters applied + + )} +
)}
); +} + +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 = ; + onClick = onRetry; + } else if (["mirrored", "synced", "syncing"].includes(repo.status)) { + label = "Sync"; + icon = ; + onClick = onSync; + disabled ||= repo.status === "syncing"; + } else if (["imported", "mirroring"].includes(repo.status)) { + label = "Mirror"; + icon = ; + onClick = onMirror; + disabled ||= repo.status === "mirroring"; + } else { + return null; // unsupported status + } + + return ( + + ); } \ No newline at end of file