diff --git a/bun.lock b/bun.lock index c975194..3feb12d 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "tw-animate-css": "^1.3.5", "typescript": "^5.8.3", "uuid": "^11.1.0", + "vaul": "^1.1.2", "zod": "^3.25.75", }, "devDependencies": { @@ -1453,6 +1454,8 @@ "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], diff --git a/package.json b/package.json index 7f92bee..05e5838 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "tw-animate-css": "^1.3.5", "typescript": "^5.8.3", "uuid": "^11.1.0", + "vaul": "^1.1.2", "zod": "^3.25.75" }, "devDependencies": { 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/activity/ActivityLog.tsx b/src/components/activity/ActivityLog.tsx index 001d05a..d650d14 100644 --- a/src/components/activity/ActivityLog.tsx +++ b/src/components/activity/ActivityLog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState, useRef } from 'react'; import { Button } from '@/components/ui/button'; -import { ChevronDown, Download, RefreshCw, Search, Trash2 } from 'lucide-react'; +import { ChevronDown, Download, RefreshCw, Search, Trash2, Filter } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -36,6 +36,16 @@ import { toast } from 'sonner'; import { useLiveRefresh } from '@/hooks/useLiveRefresh'; import { useConfigStatus } from '@/hooks/useConfigStatus'; import { useNavigation } from '@/components/layout/MainLayout'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/drawer'; type MirrorJobWithKey = MirrorJob & { _rowKey: string }; @@ -343,18 +353,225 @@ export function ActivityLog() { setShowCleanupDialog(false); }; + // Check if any filters are active + const hasActiveFilters = !!(filter.status || filter.type || filter.name); + const activeFilterCount = [filter.status, filter.type, filter.name].filter(Boolean).length; + + // Clear all filters + const clearFilters = () => { + setFilter({ + searchTerm: filter.searchTerm, + status: '', + type: '', + name: '', + }); + }; + /* ------------------------------ UI ------------------------------ */ return ( -
-
+
+ {/* Mobile: Search bar with filter and action buttons */} +
+
+
+ + + setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) + } + /> +
+ + {/* Mobile Filter Drawer */} + + + + + + + Filter Activities + + Narrow down your activity log + + + +
+ {/* Active filters summary */} + {hasActiveFilters && ( +
+ + {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active + + +
+ )} + + {/* Status Filter */} +
+ + +
+ + {/* Type Filter */} +
+ + +
+ + {/* Name Filter */} +
+ + setFilter((p) => ({ ...p, name }))} + /> +
+
+ + + + + + + + + +
+
+ + + + + +
+
+ + {/* Desktop: Original layout */} +
{/* search input */} -
- +
+ setFilter((prev) => ({ @@ -365,27 +582,66 @@ export function ActivityLog() { />
- {/* status select */} - + {/* Filter controls */} +
+ {/* status select */} + + + {/* type select */} + +
{/* repo/org name combobox */} setFilter((p) => ({ ...p, name }))} /> - {/* type select */} - + {/* Action buttons */} +
+ {/* export dropdown */} + + + + + + + Export as CSV + + + Export as JSON + + + - {/* export dropdown */} - - - - - - - Export as CSV - - - Export as JSON - - - + {/* refresh */} + - {/* refresh */} - - - {/* cleanup all activities */} - + {/* cleanup all activities */} + +
{/* activity list */} @@ -486,6 +727,26 @@ export function ActivityLog() { + + {/* Mobile FAB for Export - only visible on mobile */} + + + + + + + Export as CSV + + + Export as JSON + + +
); } diff --git a/src/components/activity/ActivityNameCombobox.tsx b/src/components/activity/ActivityNameCombobox.tsx index bdac6f7..77d24f2 100644 --- a/src/components/activity/ActivityNameCombobox.tsx +++ b/src/components/activity/ActivityNameCombobox.tsx @@ -41,9 +41,14 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa variant="outline" role="combobox" aria-expanded={open} - className="w-[180px] justify-between" + className="w-full sm:w-[180px] justify-between h-10" > - {value ? value : "All Names"} + + {value || "All names"} + @@ -62,7 +67,7 @@ export function ActivityNameCombobox({ activities, value, onChange }: ActivityNa }} > - All Names + All names {names.map((name) => ( Automation & Maintenance + + + + + + +
+

Background Operations

+

+ These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance. + Choose intervals that match your workflow and repository update frequency. +

+
+
+
+
@@ -311,21 +330,6 @@ export function AutomationSettings({
- -
-
- -
-

- Background Operations -

-

- These automated tasks run in the background to keep your mirrors up-to-date and maintain optimal database performance. - Choose intervals that match your workflow and repository update frequency. -

-
-
-
); diff --git a/src/components/config/ConfigTabs.tsx b/src/components/config/ConfigTabs.tsx index 4f3b17c..d6fdbbf 100644 --- a/src/components/config/ConfigTabs.tsx +++ b/src/components/config/ConfigTabs.tsx @@ -563,17 +563,17 @@ export function ConfigTabs() { ) : (
{/* Header section */} -
+

- Configuration Settings + Configuration

Configure your GitHub and Gitea connections, and set up automatic mirroring.

-
+
@@ -200,6 +202,17 @@ export function GitHubConfigForm({ if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions); }} /> + + {/* Mobile: Show button at bottom */} + diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx index 7799de8..9c6c779 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -124,7 +124,7 @@ export function GitHubMirrorSettings({
-
+
- {/* Starred repos content selection - inline to prevent layout shift */} + {/* Starred repos content selection - responsive layout */}
-
+
- {/* Metadata multi-select - inline to prevent layout shift */} + {/* Metadata multi-select - responsive layout */} - + diff --git a/src/components/config/OrganizationStrategy.tsx b/src/components/config/OrganizationStrategy.tsx index 4759cfc..c6f0315 100644 --- a/src/components/config/OrganizationStrategy.tsx +++ b/src/components/config/OrganizationStrategy.tsx @@ -24,7 +24,7 @@ const strategyConfig = { preserve: { title: "Preserve Structure", icon: FolderTree, - description: "Keep the exact same organization structure as GitHub", + description: "Keep the exact same org structure as GitHub", color: "text-blue-600 dark:text-blue-400", bgColor: "bg-blue-50 dark:bg-blue-950/20", borderColor: "border-blue-200 dark:border-blue-900", @@ -60,7 +60,7 @@ const strategyConfig = { "mixed": { title: "Mixed Mode", icon: GitBranch, - description: "user repos in single org, org repos preserve structure", + description: "Personal repos in single org, org repos preserve structure", color: "text-orange-600 dark:text-orange-400", bgColor: "bg-orange-50 dark:bg-orange-950/20", borderColor: "border-orange-200 dark:border-orange-900", @@ -281,7 +281,7 @@ export const OrganizationStrategy: React.FC = ({ }) => { return (
-
+

@@ -303,7 +303,7 @@ export const OrganizationStrategy: React.FC = ({ Override Options - +

Fine-tune Your Mirror Destinations

@@ -371,15 +371,16 @@ export const OrganizationStrategy: React.FC = ({ !isSelected && "border-muted" )} > -
-
+
+
= ({ )} />
-
-
-

{config.title}

-
-

- {config.description} -

-
- - - - e.stopPropagation()} - > - - - - -
-

Repository Mapping Preview

- +
+
+
+

{config.title}

+

+ {config.description} +

- - + + + + e.stopPropagation()} + > + + + + +
+

Repository Mapping Preview

+ +
+
+
+
+
diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 05514fb..2cc9e67 100644 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -179,16 +179,16 @@ export function Dashboard() { return isLoading || !connected ? (
-
+
-
+
{/* Repository List Skeleton */} -
+
@@ -201,7 +201,7 @@ export function Dashboard() {
{/* Recent Activity Skeleton */} -
+
@@ -217,24 +217,24 @@ export function Dashboard() { ) : (
-
+
} - description="Repositories being mirrored" + description="Total in mirror queue" /> } - description="Successfully mirrored" + description="Synced to Gitea" /> } - description="GitHub organizations" + description="From GitHub" />
-
- +
+
+ +
- {/* 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 */} + +
); diff --git a/src/components/dashboard/RecentActivity.tsx b/src/components/dashboard/RecentActivity.tsx index f1b3bac..b3b52c7 100644 --- a/src/components/dashboard/RecentActivity.tsx +++ b/src/components/dashboard/RecentActivity.tsx @@ -16,7 +16,7 @@ export function RecentActivity({ activities }: RecentActivityProps) { View All - +
{activities.length === 0 ? (

No recent activity

@@ -31,7 +31,7 @@ export function RecentActivity({ activities }: RecentActivityProps) { />
-

+

{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 (
-
- +
+
+ {/* Hamburger Menu Button - Mobile Only */} + + + +
-
+
{showLiveButton && ( )} @@ -104,19 +125,26 @@ export function Header({ currentPage, onNavigate }: HeaderProps) { {isLoading ? ( ) : user ? ( - <> - - - - {user.username.charAt(0).toUpperCase()} - - - - + + + + + + + + Logout + + + ) : ( - )} 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 ( -
- + + ); } 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 */} + + + + + + + Filter Organizations + + Narrow down your organization list + + + +
+ {/* Active filters summary */} + {hasActiveFilters && ( +
+ + {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active + + +
+ )} + + {/* Role Filter */} +
+ + +
+ + {/* Status Filter */} +
+ + +
+
+ + + + + + + + + +
+
+ + + +
- {/* Membership Role Filter */} - + {/* Desktop: Original layout */} +
+
+ + + setFilter((prev) => ({ ...prev, searchTerm: e.target.value })) + } + /> +
- {/* Status Filter */} - + {/* Filter controls */} +
+ {/* Membership Role Filter */} + - + {/* Status Filter */} + +
- + {/* Action buttons */} +
+ + + +
+
+
{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/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 */} + + + + + + + Filter Repositories + + Narrow down your repository list + + + +
+ {/* Active filters summary */} + {hasActiveFilters && ( +
+ + {activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active + + +
+ )} - {/* Owner Combobox */} - - setFilter((prev) => ({ ...prev, owner })) - } - /> + {/* Owner Filter */} +
+ + + setFilter((prev) => ({ ...prev, owner })) + } + /> +
- {/* Organization Combobox */} - - setFilter((prev) => ({ ...prev, organization })) - } - /> + {/* Organization Filter */} +
+ + + setFilter((prev) => ({ ...prev, organization })) + } + /> +
- - - - - {/* Context-aware action buttons */} - {selectedRepoIds.size === 0 ? ( + {/* Status Filter */} +
+ + +
+
+ + + + + + + + + +
+
+ + + - ) : ( -
-
+
+ + {/* 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 */} +
+ + + +
+ + {/* 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 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') && ( - - )} - - {availableActions.includes('sync') && ( - - )} - - {availableActions.includes('retry') && ( - - )} +
+ {availableActions.includes('mirror') && ( + + )} + + {availableActions.includes('sync') && ( + + )} + + {availableActions.includes('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.status} +
+ + {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never mirrored"} + +
+
+ + {/* Actions */} +
+ {/* Primary action button */} + {(repo.status === "imported" || repo.status === "failed") && ( + + )} + {(repo.status === "mirrored" || repo.status === "synced") && ( + + )} + {repo.status === "failed" && ( + + )} + + {/* External links */} +
+ + {giteaUrl ? ( + + ) : ( + + )} +
+
+
+ + + ); + }; + 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 ? ( - - ) : ( - - )} + + {Array.from({ length: 5 }).map((_, i) => ( +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ ))} +
) : ( -
- {/* table header */} -
-
- +
+ {hasAnyFilter && ( +
+ + Showing {filteredRepositories.length} of {repositories.length} repositories + +
-
- 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 */} -
-

{repo.owner}

-
- - {/* Organization */} -
- -
- - {/* Last Mirrored */} -
-

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

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

{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 */} +
+

{repo.owner}

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

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

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

{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 && ( +
+
+ + Live active + +
+
+ )} + + {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; + } +}