feat(nav): collapsible sidebar + mobile drawer, user menu, breadcrumbs, polished cmd+k, header search
Build & Push Docker Image / build (push) Successful in 2m46s
Build & Push Docker Image / build (push) Successful in 2m46s
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name?: string | null): string {
|
||||
if (!name) return "?";
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function UserMenu() {
|
||||
const [location, navigate] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const showWatchlist = hasFeature("watchlist");
|
||||
const showTrash = hasFeature("trash");
|
||||
const displayName = user?.name || user?.preferredUsername || "User";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={() => login(location)}
|
||||
data-testid="button-login"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
{t("auth.signIn")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
setOpen(false);
|
||||
navigate(path);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-3 px-3 py-2 h-auto"
|
||||
data-testid="button-user-menu"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary/15 text-primary text-xs font-semibold">
|
||||
{initials(displayName)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block text-sm font-medium truncate">{displayName}</span>
|
||||
<span className="block text-[11px] text-muted-foreground truncate">
|
||||
{user.email ?? tier}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel className="flex items-center justify-between gap-2 font-normal">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground truncate">{displayName}</span>
|
||||
{user.email && <span className="block text-xs text-muted-foreground truncate">{user.email}</span>}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] uppercase tracking-wider",
|
||||
isAdmin && "border-primary text-primary",
|
||||
)}
|
||||
>
|
||||
{isAdmin ? "admin" : tier}
|
||||
</Badge>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{(showWatchlist || showTrash) && (
|
||||
<>
|
||||
{showWatchlist && (
|
||||
<DropdownMenuItem onSelect={() => go("/watchlist")}>
|
||||
<Bookmark className="mr-2 h-4 w-4" />
|
||||
{t("nav.watchlist")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showTrash && (
|
||||
<DropdownMenuItem onSelect={() => go("/trash")}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("nav.trash")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={logout}
|
||||
data-testid="button-logout"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("auth.signOut")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user