feat(frontend): add mobile bottom nav and redesign profile
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import MobileBottomNav from "@/components/MobileBottomNav";
|
||||||
import Navbar from "@/components/Navbar";
|
import Navbar from "@/components/Navbar";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import Providers from "@/components/providers";
|
import Providers from "@/components/providers";
|
||||||
@@ -39,8 +40,11 @@ export default function RootLayout({
|
|||||||
<RouteProgress />
|
<RouteProgress />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
<div className="min-h-screen pb-28 md:pb-0">
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</div>
|
||||||
|
<MobileBottomNav />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
99
src/components/MobileBottomNav.tsx
Normal file
99
src/components/MobileBottomNav.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { CalendarDays, CircleUserRound, Home, Newspaper } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon: ComponentType<{ className?: string }>;
|
||||||
|
matches: (pathname: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isProfileOrAuthPath = (pathname: string) =>
|
||||||
|
pathname === "/profile" ||
|
||||||
|
pathname.startsWith("/profile/") ||
|
||||||
|
pathname === "/auth" ||
|
||||||
|
pathname.startsWith("/auth/") ||
|
||||||
|
pathname.startsWith("/reset-password") ||
|
||||||
|
pathname.startsWith("/verify-email");
|
||||||
|
|
||||||
|
export default function MobileBottomNav() {
|
||||||
|
const pathname = usePathname() || "/";
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
if (pathname.startsWith("/admin") || pathname === "/logout") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{
|
||||||
|
key: "home",
|
||||||
|
label: "خانه",
|
||||||
|
href: "/",
|
||||||
|
icon: Home,
|
||||||
|
matches: (current) => current === "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "events",
|
||||||
|
label: "رویدادها",
|
||||||
|
href: "/events",
|
||||||
|
icon: CalendarDays,
|
||||||
|
matches: (current) => current === "/events" || current.startsWith("/events/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "blog",
|
||||||
|
label: "بلاگ",
|
||||||
|
href: "/blog",
|
||||||
|
icon: Newspaper,
|
||||||
|
matches: (current) => current === "/blog" || current.startsWith("/blog/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "account",
|
||||||
|
label: isAuthenticated ? "پروفایل" : "حساب",
|
||||||
|
href: isAuthenticated ? "/profile" : "/auth",
|
||||||
|
icon: CircleUserRound,
|
||||||
|
matches: isProfileOrAuthPath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-x-0 z-50 px-4 md:hidden"
|
||||||
|
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const active = item.matches(pathname);
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[11px] font-medium transition-all",
|
||||||
|
active
|
||||||
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
|
||||||
|
)}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import type { ReactNode } from "react";
|
||||||
import { Link, NavLink, useNavigate } from '@/lib/router';
|
import { useMemo } from "react";
|
||||||
import { Menu, ChevronDown } from 'lucide-react';
|
import { Link, NavLink } from "@/lib/router";
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { Button } from '@/components/ui/button';
|
import ModeToggle from "@/components/ModeToggle";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import ModeToggle from '@/components/ModeToggle';
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const NavItem = ({
|
const NavItem = ({
|
||||||
to,
|
to,
|
||||||
children,
|
children,
|
||||||
onClick,
|
|
||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
children: React.ReactNode;
|
children: ReactNode;
|
||||||
onClick?: () => void;
|
|
||||||
}) => (
|
}) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
onClick={onClick}
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
cn(
|
||||||
'px-2 py-1 rounded-md transition-colors',
|
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
|
||||||
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
isActive
|
||||||
].join(' ')
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -41,164 +32,81 @@ const NavItem = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { user, isAuthenticated } = useAuth();
|
||||||
const isAdminUser = isAuthenticated && ((user?.is_staff || user?.is_superuser) ?? false);
|
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser);
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const avatarInitials = useMemo(
|
const avatarInitials = useMemo(
|
||||||
() => (user?.first_name?.[0] || user?.last_name?.[0] || user?.username?.[0] || '?').toUpperCase(),
|
() =>
|
||||||
|
(user?.first_name?.[0] ||
|
||||||
|
user?.last_name?.[0] ||
|
||||||
|
user?.username?.[0] ||
|
||||||
|
"?").toUpperCase(),
|
||||||
[user?.first_name, user?.last_name, user?.username],
|
[user?.first_name, user?.last_name, user?.username],
|
||||||
);
|
);
|
||||||
|
|
||||||
const UserDropdown = () => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-muted/40 px-2 py-1 pr-2.5 transition hover:bg-muted"
|
|
||||||
>
|
|
||||||
<Avatar className="h-9 w-9">
|
|
||||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
|
||||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56 text-right">
|
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
||||||
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/profile">پروفایل</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{isAdminUser && (
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/admin">داشبورد مدیریت</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
className="flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<span>حالت نمایش</span>
|
|
||||||
<ModeToggle />
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
navigate('/logout');
|
|
||||||
}}
|
|
||||||
className="text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
خروج
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60" dir="rtl">
|
<nav
|
||||||
|
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
<div className="container mx-auto px-4 py-3">
|
<div className="container mx-auto px-4 py-3">
|
||||||
<div className="flex flex-row-reverse items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Link to="/" className="order-2 flex items-center gap-2">
|
<Link to="/" className="flex min-w-0 items-center gap-3">
|
||||||
<span className="sm:inline text-2xl font-bold text-primary">
|
<div className="hidden h-10 w-10 items-center justify-center rounded-2xl border border-border/70 bg-background/90 shadow-sm sm:flex">
|
||||||
|
<span className="text-lg font-bold text-primary">گ</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||||
انجمن علمی کامپیوتر گیلان
|
انجمن علمی کامپیوتر گیلان
|
||||||
</span>
|
</p>
|
||||||
|
<p className="hidden text-xs text-muted-foreground sm:block">
|
||||||
|
رویدادها، بلاگ و حساب کاربری
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="order-1 hidden md:flex items-center gap-2">
|
<div className="hidden items-center gap-2 md:flex">
|
||||||
<NavItem to="/">خانه</NavItem>
|
<NavItem to="/">خانه</NavItem>
|
||||||
<NavItem to="/blog">بلاگ</NavItem>
|
<NavItem to="/blog">بلاگ</NavItem>
|
||||||
<NavItem to="/events">رویدادها</NavItem>
|
<NavItem to="/events">رویدادها</NavItem>
|
||||||
{isAuthenticated ? (
|
|
||||||
<UserDropdown />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link to="/auth">
|
|
||||||
<Button size="sm">ورود / ثبتنام</Button>
|
|
||||||
</Link>
|
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="order-1 md:hidden">
|
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" aria-label="U.U+U^">
|
|
||||||
<Menu className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="right" className="w-[80vw] sm:w-[360px]" dir="rtl">
|
|
||||||
<div className="mt-6 flex flex-col gap-4 text-right">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<img src="/favicon.ico" alt="لوگو" className="h-8 w-auto" height={32} width={32} />
|
|
||||||
<span className="text-xl font-semibold text-primary">انجمن علمی کامپیوتر گیلان</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<NavItem to="/" onClick={() => setOpen(false)}>خانه</NavItem>
|
|
||||||
<NavItem to="/blog" onClick={() => setOpen(false)}>بلاگ</NavItem>
|
|
||||||
<NavItem to="/events" onClick={() => setOpen(false)}>رویدادها</NavItem>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t grid gap-3">
|
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<Link
|
||||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
to="/profile"
|
||||||
<Avatar className="h-10 w-10">
|
className="flex items-center gap-3 rounded-full border border-border/70 bg-background/85 px-2 py-1.5 transition hover:bg-muted/70"
|
||||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
>
|
||||||
|
<Avatar className="h-9 w-9">
|
||||||
|
<AvatarImage
|
||||||
|
src={
|
||||||
|
user?.profile_picture_preview_url ||
|
||||||
|
user?.profile_picture ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
alt={user?.username || "profile"}
|
||||||
|
/>
|
||||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 text-right">
|
<div className="min-w-0 text-right">
|
||||||
<div className="font-medium">{user?.username}</div>
|
<p className="max-w-36 truncate text-sm font-medium text-foreground">
|
||||||
{user?.email ? <div className="text-xs text-muted-foreground">{user.email}</div> : null}
|
{user?.first_name || user?.last_name
|
||||||
|
? `${user?.first_name || ""} ${user?.last_name || ""}`.trim()
|
||||||
|
: user?.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isAdminUser ? "پروفایل و مدیریت حساب" : "پروفایل من"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
|
|
||||||
<Link to="/profile">پروفایل</Link>
|
|
||||||
</Button>
|
|
||||||
{isAdminUser && (
|
|
||||||
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
|
|
||||||
<Link to="/admin">داشبورد مدیریت</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
|
||||||
<span className="text-sm text-muted-foreground">حالت نمایش</span>
|
|
||||||
<ModeToggle />
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="justify-between text-red-600 border-red-600 hover:bg-red-50 dark:text-red-400 dark:border-red-400 dark:hover:bg-red-950/30"
|
|
||||||
onClick={() => { setOpen(false); navigate('/logout'); }}
|
|
||||||
>
|
|
||||||
خروج
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Link to="/auth" onClick={() => setOpen(false)}>
|
|
||||||
<Button className="w-full">ورود / ثبتنام</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">حالت نمایش</span>
|
<Link to="/auth">
|
||||||
<ModeToggle />
|
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
|
||||||
</div>
|
</Link>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SheetContent>
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
</Sheet>
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user