feat: improve mobile layout across components

- Update ActivityLog component for better mobile responsiveness
- Enhance Header layout for mobile devices
- Improve mobile UX in AddOrganizationDialog
- Optimize Organization component mobile display
- Enhance AddRepositoryDialog mobile layout
This commit is contained in:
Arunavo Ray
2025-07-07 18:51:24 +05:30
parent 24bd0aefe6
commit b984ff9af4
6 changed files with 249 additions and 199 deletions

View File

@@ -346,10 +346,10 @@ export function ActivityLog() {
/* ------------------------------ UI ------------------------------ */ /* ------------------------------ UI ------------------------------ */
return ( return (
<div className='flex flex-col gap-y-8'> <div className='flex flex-col gap-y-4 sm:gap-y-8'>
<div className='flex w-full flex-row items-center gap-4'> <div className='flex w-full flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4'>
{/* search input */} {/* search input */}
<div className='relative flex-1'> <div className='relative w-full sm:flex-1'>
<Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' /> <Search className='absolute left-2 top-2.5 h-4 w-4 text-muted-foreground' />
<input <input
type='text' type='text'
@@ -365,93 +365,102 @@ export function ActivityLog() {
/> />
</div> </div>
{/* status select */} {/* Filter controls row */}
<Select <div className='flex flex-row items-center gap-2 w-full sm:w-auto'>
value={filter.status || 'all'} {/* status select */}
onValueChange={(v) => <Select
setFilter((p) => ({ value={filter.status || 'all'}
...p, onValueChange={(v) =>
status: v === 'all' ? '' : (v as RepoStatus), setFilter((p) => ({
})) ...p,
} status: v === 'all' ? '' : (v as RepoStatus),
> }))
<SelectTrigger className='h-9 w-[140px] max-h-9'> }
<SelectValue placeholder='All Status' /> >
</SelectTrigger> <SelectTrigger className='h-9 w-full sm:w-[140px] max-h-9'>
<SelectContent> <SelectValue placeholder='All Status' />
{['all', ...repoStatusEnum.options].map((s) => ( </SelectTrigger>
<SelectItem key={s} value={s}> <SelectContent>
{s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)} {['all', ...repoStatusEnum.options].map((s) => (
</SelectItem> <SelectItem key={s} value={s}>
))} {s === 'all' ? 'All Status' : s[0].toUpperCase() + s.slice(1)}
</SelectContent> </SelectItem>
</Select> ))}
</SelectContent>
</Select>
{/* repo/org name combobox */} {/* type select - hidden on mobile */}
<ActivityNameCombobox <Select
activities={activities} value={filter.type || 'all'}
value={filter.name || ''} onValueChange={(v) =>
onChange={(name) => setFilter((p) => ({ ...p, name }))} setFilter((p) => ({ ...p, type: v === 'all' ? '' : v }))
/> }
>
<SelectTrigger className='h-9 w-[140px] max-h-9 hidden sm:flex'>
<SelectValue placeholder='All Types' />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}>
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* type select */} {/* repo/org name combobox - hidden on mobile */}
<Select <div className='hidden sm:block'>
value={filter.type || 'all'} <ActivityNameCombobox
onValueChange={(v) => activities={activities}
setFilter((p) => ({ ...p, type: v === 'all' ? '' : v })) value={filter.name || ''}
} onChange={(name) => setFilter((p) => ({ ...p, name }))}
> />
<SelectTrigger className='h-9 w-[140px] max-h-9'> </div>
<SelectValue placeholder='All Types' />
</SelectTrigger>
<SelectContent>
{['all', 'repository', 'organization'].map((t) => (
<SelectItem key={t} value={t}>
{t === 'all' ? 'All Types' : t[0].toUpperCase() + t.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{/* export dropdown */} {/* Action buttons row */}
<DropdownMenu> <div className='flex items-center gap-2 ml-auto'>
<DropdownMenuTrigger asChild> {/* export dropdown - text hidden on mobile */}
<Button variant='outline' className='flex items-center gap-1'> <DropdownMenu>
<Download className='mr-1 h-4 w-4' /> <DropdownMenuTrigger asChild>
Export <Button variant='outline' size='sm' className='flex items-center gap-1'>
<ChevronDown className='ml-1 h-4 w-4' /> <Download className='h-4 w-4' />
</Button> <span className='hidden sm:inline'>Export</span>
</DropdownMenuTrigger> <ChevronDown className='h-4 w-4 hidden sm:inline' />
<DropdownMenuContent> </Button>
<DropdownMenuItem onClick={exportAsCSV}> </DropdownMenuTrigger>
Export as CSV <DropdownMenuContent>
</DropdownMenuItem> <DropdownMenuItem onClick={exportAsCSV}>
<DropdownMenuItem onClick={exportAsJSON}> Export as CSV
Export as JSON </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem onClick={exportAsJSON}>
</DropdownMenuContent> Export as JSON
</DropdownMenu> </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* refresh */} {/* refresh */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton onClick={() => fetchActivities(false)} // Manual refresh, show loading skeleton
title="Refresh activity log" title="Refresh activity log"
> className='h-8 w-8 sm:h-9 sm:w-9'
<RefreshCw className='h-4 w-4' /> >
</Button> <RefreshCw className='h-4 w-4' />
</Button>
{/* cleanup all activities */} {/* cleanup all activities */}
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleCleanupClick} onClick={handleCleanupClick}
title="Delete all activities" title="Delete all activities"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive h-8 w-8 sm:h-9 sm:w-9"
> >
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
</Button> </Button>
</div>
</div> </div>
{/* activity list */} {/* activity list */}

View File

@@ -7,7 +7,13 @@ import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useLiveRefresh } from "@/hooks/useLiveRefresh"; import { useLiveRefresh } from "@/hooks/useLiveRefresh";
import { useConfigStatus } from "@/hooks/useConfigStatus"; import { useConfigStatus } from "@/hooks/useConfigStatus";
import { Menu } from "lucide-react"; import { Menu, LogOut } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface HeaderProps { interface HeaderProps {
currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log"; currentPage?: "dashboard" | "repositories" | "organizations" | "configuration" | "activity-log";
@@ -60,9 +66,9 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Hamburger Menu Button - Mobile Only */} {/* Hamburger Menu Button - Mobile Only */}
<Button <Button
variant="ghost" variant="outline"
size="sm" size="lg"
className="lg:hidden p-2" className="lg:hidden"
onClick={onMenuClick} onClick={onMenuClick}
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
@@ -96,12 +102,12 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
{showLiveButton && ( {showLiveButton && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="lg"
className="flex items-center gap-2 sm:px-4" className="flex items-center gap-1.5 px-3 sm:px-4"
onClick={toggleLive} onClick={toggleLive}
title={getTooltip()} title={getTooltip()}
> >
<div className={`w-3 h-3 rounded-full ${ <div className={`size-4 sm:size-3 rounded-full ${
configLoading configLoading
? 'bg-yellow-400 animate-pulse' ? 'bg-yellow-400 animate-pulse'
: isLiveActive : isLiveActive
@@ -110,7 +116,7 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
? 'bg-orange-400' ? 'bg-orange-400'
: 'bg-gray-500' : 'bg-gray-500'
}`} /> }`} />
<span className="hidden sm:inline">LIVE</span> <span className="text-sm font-medium hidden sm:inline">LIVE</span>
</Button> </Button>
)} )}
@@ -120,15 +126,40 @@ export function Header({ currentPage, onNavigate, onMenuClick }: HeaderProps) {
<AuthButtonsSkeleton /> <AuthButtonsSkeleton />
) : user ? ( ) : user ? (
<> <>
<Avatar> {/* Desktop: Show avatar and logout button */}
<AvatarImage src="" alt="@shadcn" /> <div className="hidden sm:flex sm:items-center sm:gap-4">
<AvatarFallback> <Avatar>
{user.username.charAt(0).toUpperCase()} <AvatarImage src="" alt="@shadcn" />
</AvatarFallback> <AvatarFallback>
</Avatar> {user.username.charAt(0).toUpperCase()}
<Button variant="outline" size="sm" onClick={handleLogout} className="hidden sm:inline-flex"> </AvatarFallback>
Logout </Avatar>
</Button> <Button variant="outline" size="sm" onClick={handleLogout}>
Logout
</Button>
</div>
{/* Mobile: Avatar with dropdown */}
<div className="sm:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg" className="relative h-10 w-10 rounded-full p-0">
<Avatar className="h-full w-full">
<AvatarImage src="" alt="@shadcn" />
<AvatarFallback>
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer">
<LogOut className="h-4 w-4 mr-2" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</> </>
) : ( ) : (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>

View File

@@ -78,7 +78,7 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
)} )}
> >
<div className="flex flex-col h-full pt-4"> <div className="flex flex-col h-full pt-4">
<nav className="flex flex-col gap-y-1 pl-2 pr-3"> <nav className="flex flex-col gap-y-1 lg:gap-y-1 pl-2 pr-3">
{links.map((link, index) => { {links.map((link, index) => {
const isActive = currentPath === link.href; const isActive = currentPath === link.href;
const Icon = link.icon; const Icon = link.icon;
@@ -88,13 +88,13 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
key={index} key={index}
onClick={(e) => handleNavigation(link.href, e)} onClick={(e) => handleNavigation(link.href, e)}
className={cn( className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors w-full text-left", "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 isActive
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-5 w-5 lg:h-4 lg:w-4" />
{link.label} {link.label}
</button> </button>
); );
@@ -102,19 +102,19 @@ export function Sidebar({ className, onNavigate, isOpen, onClose }: SidebarProps
</nav> </nav>
<div className="mt-auto px-4 py-4"> <div className="mt-auto px-4 py-4">
<div className="rounded-md bg-muted p-3"> <div className="rounded-md bg-muted p-3 lg:p-3">
<h4 className="text-sm font-medium mb-2">Need Help?</h4> <h4 className="text-sm font-medium mb-2">Need Help?</h4>
<p className="text-xs text-muted-foreground mb-2"> <p className="text-xs text-muted-foreground mb-3 lg:mb-2">
Check out the documentation for help with setup and configuration. Check out the documentation for help with setup and configuration.
</p> </p>
<a <a
href="/docs" href="/docs"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-primary hover:underline" className="inline-flex items-center gap-1.5 text-xs lg:text-xs text-primary hover:underline py-2 lg:py-0"
> >
Documentation Documentation
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3.5 w-3.5 lg:h-3 lg:w-3" />
</a> </a>
</div> </div>
<VersionInfo /> <VersionInfo />

View File

@@ -63,12 +63,12 @@ export default function AddOrganizationDialog({
return ( return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0"> <Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
<Plus className="h-6 w-6" /> <Plus className="h-6 w-6" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6"> <DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Organization</DialogTitle> <DialogTitle>Add Organization</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@@ -293,10 +293,10 @@ export function Organization() {
return ( return (
<div className="flex flex-col gap-y-8"> <div className="flex flex-col gap-y-4 sm:gap-y-8">
{/* Combine search and actions into a single flex row */} {/* Search and filters */}
<div className="flex flex-row items-center gap-4 w-full flex-wrap"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-4 w-full">
<div className="relative flex-grow"> <div className="relative w-full sm:flex-grow">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<input <input
type="text" type="text"
@@ -309,91 +309,101 @@ export function Organization() {
/> />
</div> </div>
{/* Membership Role Filter */} {/* Filter controls */}
<Select <div className="flex items-center gap-2 w-full sm:w-auto">
value={filter.membershipRole || "all"} {/* Membership Role Filter */}
onValueChange={(value) => <Select
setFilter((prev) => ({ value={filter.membershipRole || "all"}
...prev, onValueChange={(value) =>
membershipRole: value === "all" ? "" : (value as MembershipRole), setFilter((prev) => ({
})) ...prev,
} membershipRole: value === "all" ? "" : (value as MembershipRole),
> }))
<SelectTrigger className="w-[140px] h-9 max-h-9"> }
<SelectValue placeholder="All Roles" /> >
</SelectTrigger> <SelectTrigger className="w-full sm:w-[140px] h-9 max-h-9">
<SelectContent> <SelectValue placeholder="All Roles" />
{["all", ...membershipRoleEnum.options].map((role) => ( </SelectTrigger>
<SelectItem key={role} value={role}> <SelectContent>
{role === "all" {["all", ...membershipRoleEnum.options].map((role) => (
? "All Roles" <SelectItem key={role} value={role}>
: role {role === "all"
.replace(/_/g, " ") ? "All Roles"
.replace(/\b\w/g, (c) => c.toUpperCase())} : role
</SelectItem> .replace(/_/g, " ")
))} .replace(/\b\w/g, (c) => c.toUpperCase())}
</SelectContent> </SelectItem>
</Select> ))}
</SelectContent>
</Select>
{/* Status Filter */} {/* Status Filter */}
<Select <Select
value={filter.status || "all"} value={filter.status || "all"}
onValueChange={(value) => onValueChange={(value) =>
setFilter((prev) => ({ setFilter((prev) => ({
...prev, ...prev,
status: status:
value === "all" value === "all"
? "" ? ""
: (value as : (value as
| "" | ""
| "imported" | "imported"
| "mirroring" | "mirroring"
| "mirrored" | "mirrored"
| "failed" | "failed"
| "syncing" | "syncing"
| "synced"), | "synced"),
})) }))
} }
> >
<SelectTrigger className="w-[140px] h-9 max-h-9"> <SelectTrigger className="w-full sm:w-[140px] h-9 max-h-9">
<SelectValue placeholder="All Statuses" /> <SelectValue placeholder="All Statuses" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{[ {[
"all", "all",
"imported", "imported",
"mirroring", "mirroring",
"mirrored", "mirrored",
"failed", "failed",
"syncing", "syncing",
"synced", "synced",
].map((status) => ( ].map((status) => (
<SelectItem key={status} value={status}> <SelectItem key={status} value={status}>
{status === "all" {status === "all"
? "All Statuses" ? "All Statuses"
: status.charAt(0).toUpperCase() + status.slice(1)} : status.charAt(0).toUpperCase() + status.slice(1)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
<Button {/* Action buttons */}
variant="outline" <div className="flex items-center gap-2 ml-auto">
size="icon" <Button
onClick={handleRefresh} variant="outline"
title="Refresh organizations" size="icon"
> onClick={handleRefresh}
<RefreshCw className="h-4 w-4" /> title="Refresh organizations"
</Button> className="h-8 w-8 sm:h-9 sm:w-9"
>
<RefreshCw className="h-4 w-4" />
</Button>
<Button <Button
variant="default" variant="default"
onClick={handleMirrorAllOrgs} size="sm"
disabled={isLoading || loadingOrgIds.size > 0} onClick={handleMirrorAllOrgs}
> disabled={isLoading || loadingOrgIds.size > 0}
<FlipHorizontal className="h-4 w-4 mr-2" /> className="sm:size-default"
Mirror All >
</Button> <FlipHorizontal className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Mirror All</span>
<span className="sm:hidden">Mirror</span>
</Button>
</div>
</div> </div>
<OrganizationList <OrganizationList

View File

@@ -60,12 +60,12 @@ export default function AddRepositoryDialog({
return ( return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="fixed bottom-6 right-6 rounded-full h-12 w-12 shadow-lg p-0"> <Button className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 rounded-full h-12 w-12 shadow-lg p-0 z-10">
<Plus className="h-6 w-6" /> <Plus className="h-6 w-6" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px] gap-0 gap-y-6"> <DialogContent className="w-[calc(100%-2rem)] sm:max-w-[425px] gap-0 gap-y-6 mx-4 sm:mx-0">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Repository</DialogTitle> <DialogTitle>Add Repository</DialogTitle>
<DialogDescription> <DialogDescription>