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..e34b482 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -7,13 +7,15 @@ import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useConfigStatus } from "@/hooks/useConfigStatus"; +import { Menu } from "lucide-react"; 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,35 +56,48 @@ export function Header({ currentPage, onNavigate }: HeaderProps) { return (
-
- +
+
+ {/* Hamburger Menu Button - Mobile Only */} + + + +
-
+
{showLiveButton && ( )} @@ -111,12 +126,12 @@ export function Header({ currentPage, onNavigate }: HeaderProps) { {user.username.charAt(0).toUpperCase()} - ) : ( - )} 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..29b81cd 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,75 @@ 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/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 01b2202..5049590 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -560,10 +560,10 @@ export default function Repository() { const availableActions = getAvailableActions(); return ( -
- {/* Combine search and actions into a single flex row */} -
-
+
+ {/* Search and filters */} +
+
- + {/* Filter controls in a responsive row */} +
+ - + +
+
- {/* Context-aware action buttons */} + {/* Action buttons - separate row on mobile */} +
{selectedRepoIds.size === 0 ? ( ) : ( -
+ <>
{selectedRepoIds.size} selected @@ -652,42 +659,44 @@ export default function Repository() {
- {availableActions.includes('mirror') && ( - - )} - - {availableActions.includes('sync') && ( - - )} - - {availableActions.includes('retry') && ( - - )} -
+
+ {availableActions.includes('mirror') && ( + + )} + + {availableActions.includes('sync') && ( + + )} + + {availableActions.includes('retry') && ( + + )} +
+ )}
diff --git a/src/components/repositories/RepositoryComboboxes.tsx b/src/components/repositories/RepositoryComboboxes.tsx index f77c11a..56f3ae4 100644 --- a/src/components/repositories/RepositoryComboboxes.tsx +++ b/src/components/repositories/RepositoryComboboxes.tsx @@ -33,13 +33,13 @@ export function OwnerCombobox({ options, value, onChange, placeholder = "Owner" variant="outline" role="combobox" aria-expanded={open} - className="w-[160px] justify-between" + className="w-full sm:w-[160px] justify-between" > {value ? value : placeholder} - + @@ -86,13 +86,13 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = " variant="outline" role="combobox" aria-expanded={open} - className="w-[160px] justify-between" + className="w-full sm:w-[160px] justify-between" > {value ? value : placeholder} - + @@ -128,4 +128,4 @@ export function OrganizationCombobox({ options, value, onChange, placeholder = " ); -} +} \ No newline at end of file diff --git a/src/components/repositories/RepositoryTable.tsx b/src/components/repositories/RepositoryTable.tsx index 0585500..9abfe67 100644 --- a/src/components/repositories/RepositoryTable.tsx +++ b/src/components/repositories/RepositoryTable.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef } from "react"; import Fuse from "fuse.js"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react"; +import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock } from "lucide-react"; import { SiGithub, SiGitea } from "react-icons/si"; import type { Repository } from "@/lib/db/schema"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { InlineDestinationEditor } from "./InlineDestinationEditor"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; interface RepositoryTableProps { repositories: Repository[]; @@ -166,283 +168,89 @@ export default function RepositoryTable({ filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id)); const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected; - return isLoading ? ( -
-
-
- -
-
- Repository -
-
Owner
-
- Organization -
-
- Last Mirrored -
-
Status
-
- Actions -
-
- Links -
-
+ // Mobile card layout for repository + const RepositoryCard = ({ repo }: { repo: Repository }) => { + const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false; + const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false; + const giteaUrl = getGiteaRepoUrl(repo); - {Array.from({ length: 5 }).map((_, i) => ( -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- ))} -
- ) : filteredRepositories.length === 0 ? ( -
- -

No repositories found

-

- {hasAnyFilter - ? "Try adjusting your search or filter criteria." - : "Configure your GitHub connection to start mirroring repositories."} -

- {hasAnyFilter ? ( - - ) : ( - - )} -
- ) : ( -
- {/* 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}`} - /> + return ( + + +
+ repo.id && handleSelectRepo(repo.id, checked as boolean)} + className="mt-1" + /> +
+ {/* Repository Info */} +
+

{repo.name}

+
+ {repo.isPrivate && Private} + {repo.isForked && Fork} + {repo.isStarred && Starred}
+
- {/* Repository */} -
- -
-
- {repo.name} - {repo.isStarred && ( - - )} -
-
- {repo.fullName} -
-
- {repo.isPrivate && ( - - Private - - )} - {repo.isForked && ( - - Fork - - )} + {/* Owner & Organization */} +
+
Owner: {repo.owner}
+ {repo.organization &&
Org: {repo.organization}
} + {repo.destinationOrg &&
Destination: {repo.destinationOrg}
} +
+ + {/* Status & Last Mirrored */} +
+
+
+ {repo.status}
+ + {repo.lastMirrored ? formatDate(repo.lastMirrored) : "Never"} + +
- {/* 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 ? ( - - ) : ( - - ); - })()} - + )} + {(repo.status === "mirrored" || repo.status === "synced") && ( + + )} + {repo.status === "failed" && ( + + )} + + {/* Links */} +
+ + {giteaUrl ? ( + + ) : ( + + )}
- ); - })} -
+
+
+ + + ); + }; + + return isLoading ? ( +
+ {/* Mobile skeleton */} +
+ {Array.from({ length: 5 }).map((_, i) => ( + + +
+ +
+ + + +
+ + +
+
+
+
+
+ ))}
- {/* Status Bar */} -
-
-
- - {hasAnyFilter - ? `Showing ${filteredRepositories.length} of ${repositories.length} repositories` - : `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`} - + {/* Desktop skeleton */} +
+
+
+ +
+
+ Repository +
+
Owner
+
+ Organization +
+
+ Last Mirrored +
+
Status
+
+ Actions +
+
+ Links +
- {/* Center - Live active indicator */} - {isLiveActive && ( -
-
- - Live active - -
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
- )} - - {hasAnyFilter && ( - - Filters applied - - )} + ))}
- ); -} + ) : ( +
+ {hasAnyFilter && ( +
+ + Showing {filteredRepositories.length} of {repositories.length} repositories + + +
+ )} -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 = ; // Don't change this icon to GitFork. - onClick = onMirror; - disabled ||= repo.status === "mirroring"; - } else { - return null; // unsupported status - } - - return ( - + )} + {(repo.status === "mirrored" || repo.status === "synced") && ( + + )} + {repo.status === "failed" && ( + + )} +
+
+ + + + + + +

View on GitHub

+
+
+
+ + {giteaUrl ? ( + + + + + + +

View on Gitea

+
+
+
+ ) : ( + + + + + + +

Not mirrored to Gitea

+
+
+
+ )} +
+
+ ); + })} +
+
+
)} - +
); -} +} \ No newline at end of file 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; + } +}