theme provider

This commit is contained in:
2025-12-24 11:49:17 +03:30
parent c5872fb762
commit 6a644b9a4e
26 changed files with 637 additions and 33 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -1,8 +1,15 @@
import type { Metadata } from "next";
import "&/fonts.css";
import "@/styles/globals.css";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import NextTopLoader from "nextjs-toploader";
import { ClientAppProvider } from "@/providers/ClientAppProvider";
import UserInitializer from "@/utils/UserInitializer";
import Modal from "@/components/UI/Modal";
import ToastProvider from "@/utils/ToastProvider";
import { Sidebar } from "@/components/SideBar";
import { AppShell } from "@/providers/AppShell";
export const metadata: Metadata = {
title: {
@@ -27,11 +34,7 @@ export const viewport = {
maximumScale: 1,
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
const messages = await getMessages({ locale });
return (
@@ -39,7 +42,15 @@ export default async function RootLayout({
<body>
<NextTopLoader color="#16a795" />
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<ClientAppProvider>
<AppShell>
<UserInitializer />
{children}
<Modal />
<ToastProvider />
<Sidebar />
</AppShell>
</ClientAppProvider>
</NextIntlClientProvider>
</body>
</html>

View File

@@ -1,2 +1,8 @@
export { default as CheckIcon } from "&/icons/check-icon.svg";
export { default as ChevronIcon } from "&/icons/chevron-icon.svg";
export { default as GoogleMapsIcon } from "&/icons/google-maps.svg";
export { default as CloseIcon } from "&/icons/close-icon.svg";
export { default as LogoutIcon } from "&/icons/logout.svg";
export { default as AvatarIcon } from "&/icons/avatar-icon.svg";
export { default as DarkModeIcon } from "&/icons/dark-mode-icon.svg";
export { default as LightModeIcon } from "&/icons/light-mode-icon.svg";

View File

@@ -0,0 +1,39 @@
import { useAuth } from "@/hooks/useAuth";
import { useModalStore } from "@/stores/useModalStore";
import { useTranslations } from "next-intl";
const LogoutModal = () => {
const t = useTranslations("logout");
const closeModal = useModalStore((s) => s.closeModal);
const { logout } = useAuth();
return (
<div className="bg-desktop-primary text-text flex flex-col gap-4 rounded-xl p-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">{t("logout")}</h2>
<p className="text-text/75 max-w-3xs text-sm">{t("confirmLogout")}</p>
</div>
<div className="flex gap-2">
<button
onClick={closeModal}
className="border-lines/40 hover:bg-lines/10 flex-1 rounded-full border px-3 py-1 text-sm"
>
{t("cancel")}
</button>
<button
onClick={() => {
logout().then(() => {
closeModal();
});
}}
className="bg-error hover:bg-error/75 flex-1 rounded-full px-3 py-1 text-sm text-white"
>
{t("logout")}
</button>
</div>
</div>
);
};
export default LogoutModal;

View File

@@ -0,0 +1,104 @@
import neshanLogo from "&/icons/neshan-logo.png";
import logo from "&/logo/144px.png";
import { CloseIcon, GoogleMapsIcon } from "@/assets";
import { useLocationStore } from "@/stores/LocationStore";
import { useModalStore } from "@/stores/useModalStore";
import Image from "next/image";
import { JSX } from "react";
type MapLauncherItem = {
id: string;
icon: JSX.Element;
label: string;
onClick: () => void;
};
export default function MapLauncherModal() {
const destination = useLocationStore((s) => s.destination);
const origin = useLocationStore((s) => s.origin);
const closeModal = useModalStore((s) => s.closeModal);
const openModal = useModalStore((s) => s.openModal);
function openGoogleMaps() {
if (!destination) return;
const originParam = origin ? `&origin=${origin.lat},${origin.lng}` : "";
const url = `https://www.google.com/maps/dir/?api=1${originParam}&destination=${destination.lat},${destination.lng}`;
window.location.href = url;
}
function open141Map() {
openModal(
<div>
<div className="bg-desktop-primary text-text flex flex-col gap-4 rounded-xl p-4">
<div className="space-y-1">
<p className="text-text/75 max-w-3xs text-sm">
به زودی میتوانید از اپلیکیشن 141 برای مسیریابی استفاده کنید.
</p>
</div>
<div className="flex gap-2">
<button
onClick={closeModal}
className="border-lines/40 hover:bg-lines/10 flex-1 rounded-full border px-3 py-1 text-sm"
>
متوجه شدم
</button>
</div>
</div>
</div>
);
}
function openNeshanMap() {
if (!destination) return;
const url = `https://nshn.ir/?lat=${destination.lat}&lng=${destination.lng}`;
window.location.href = url;
}
const launchers: MapLauncherItem[] = [
{
id: "google-maps",
icon: <GoogleMapsIcon className="size-10" />,
label: "Google Maps",
onClick: openGoogleMaps,
},
{
id: "141",
icon: <Image src={logo} alt="141 Map" className="size-10" />,
label: "141",
onClick: open141Map,
},
{
id: "neshan",
icon: <Image src={neshanLogo} alt="neshan Map" className="size-10" />,
label: "نشان",
onClick: openNeshanMap,
},
];
return (
<div className="bg-pwa-primary text-text w-full max-w-100 min-w-81 rounded-2xl">
<div className="border-lines/20 flex items-center justify-between border-b px-4 py-3">
<button onClick={closeModal} aria-label="Close">
<CloseIcon className="text-text/80 size-5" />
</button>
</div>
<div className="grid grid-cols-3 gap-3 p-4 py-6">
{launchers.map((item) => (
<button
key={item.id}
onClick={item.onClick}
className="group border-lines/20 bg-buttons/50 flex flex-col items-center justify-center gap-2 rounded-xl border p-3 shadow-sm transition hover:shadow-md active:scale-[0.97]"
>
<div className="flex items-center justify-center">{item.icon}</div>
<span className="text-text/80 text-xs font-medium">{item.label}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useModalStore } from "@/stores/useModalStore";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function RedirectToSignInModal({ confirmLabel = "ورود" }) {
const t = useTranslations("Bookmarks");
const closeModal = useModalStore((s) => s.closeModal);
const router = useRouter();
return (
<div className="bg-desktop-primary text-text flex flex-col gap-4 rounded-xl p-4">
<div className="space-y-1">
<h2 className="text-base font-semibold">{t("signInModalTitle")}</h2>
<p className="text-text/75 max-w-3xs text-sm">{t("signInModalDescription")}</p>
</div>
<div className="flex gap-2">
<button
onClick={closeModal}
className="border-lines/40 hover:bg-lines/10 flex-1 rounded-full border px-3 py-1 text-sm"
>
{t("abort")}
</button>
<button
onClick={() => {
closeModal();
router.push("/auth");
}}
className="bg-neo-aqua hover:bg-neo-aqua/75 flex-1 rounded-full px-3 py-1 text-sm text-white"
>
{t("signin")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { ReactNode } from "react";
import Link from "next/link";
import { useSidebarStore } from "@/stores/SidebarStore";
interface MenuItemProps {
label: string;
onClick?: () => void;
icon?: ReactNode;
href?: string;
}
export default function MenuItem({ label, onClick, icon, href }: MenuItemProps) {
const close = useSidebarStore((state) => state.close);
function handleClick() {
onClick?.();
close();
}
return (
<Link
href={href || "#"}
prefetch={href !== "/complaints"}
onClick={handleClick}
className="hover:bg-desktop-primary/25 flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-2 text-sm font-bold"
>
<span className="flex items-center gap-2">
{icon}
<p className="text-text line-clamp-1">{label}</p>
</span>
</Link>
);
}

View File

@@ -0,0 +1,42 @@
import { ChevronIcon } from "@/assets";
import { AnimatePresence, motion } from "framer-motion";
import { ReactNode, useState } from "react";
interface SubMenuProps {
label: string;
children: ReactNode;
}
export default function SubMenu({ label, children }: SubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="w-full">
<button
onClick={() => setIsOpen((prev) => !prev)}
className="flex w-full cursor-pointer items-center justify-between rounded-md px-2 py-2 text-sm font-bold"
>
<span className="text-text line-clamp-1">{label}</span>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.2 }}
className="bg-lines/25 rounded-lg p-1"
>
<ChevronIcon className="text-text size-4 rotate-270 opacity-50" />
</motion.div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="pr-3 pl-6"
>
{children}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import SubMenu from "./SubMenu";
import MenuItem from "./MenuItem";
import Separator from "@/components/UI/Separator";
import { motion, AnimatePresence } from "framer-motion";
type Menu = {
title: string;
href?: string;
children?: Menu[];
};
interface MenuRendererProps {
items: Menu[];
level?: number;
}
export function SideBarComponent({ items, level = 0 }: MenuRendererProps) {
return (
<div className="z-50 mt-2 flex flex-col gap-1">
{items.map((item, index) => (
<motion.div
key={item.title + index}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="mb-3"
>
{item.children ? (
<>
<AnimatePresence initial={false}>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<SubMenu label={item.title}>
<SideBarComponent items={item.children} level={level + 1} />
</SubMenu>
</motion.div>
</AnimatePresence>
</>
) : (
<MenuItem href={item.href} label={item.title} />
)}
{level === 0 && <Separator className="mt-2" />}
</motion.div>
))}
</div>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { AvatarIcon, CloseIcon, LogoutIcon } from "@/assets";
import { SideBarComponent } from "./SideBarContent";
import ThemeToggle from "@/components/ThemeToggle";
import { mobileMenuItems } from "@/data/sidebarMenu";
import { useSidebarStore } from "@/stores/SidebarStore";
import useUserStore from "@/stores/userStore";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useModalStore } from "@/stores/useModalStore";
import LogoutModal from "@/components/Modals/LogoutModal";
export const Sidebar = () => {
const t = useTranslations("Sidebar");
const ta = useTranslations("Auth");
const isOpen = useSidebarStore((s) => s.isOpen);
const isAuth = useUserStore((s) => s.isAuth);
const close = useSidebarStore((state) => state.close);
const openModal = useModalStore((s) => s.openModal);
const pathName = usePathname();
function handleClick() {
close();
}
useEffect(() => {
return () => close();
}, []);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="bg-desktop-primary/5 fixed inset-0 z-50 backdrop-blur-xl"
onClick={close}
>
<div className="absolute inset-0 flex items-center justify-center p-4">
<motion.aside
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
onClick={(e) => e.stopPropagation()}
className="bg-desktop-primary text-text flex h-full w-full max-w-md flex-col rounded-2xl p-4 shadow-lg"
>
{/* Header */}
<motion.div
key="close"
initial={{ opacity: 0, rotate: -45 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 45 }}
transition={{ duration: 0.1 }}
className={"mb-4 flex justify-between px-2"}
>
<button onClick={close}>
<CloseIcon className="text-text size-6" />
</button>
<ThemeToggle />
</motion.div>
{/* Nav Items */}
<nav className="overflow-y-auto">
{isAuth ? (
<div className="flex w-full flex-col justify-center gap-2 py-6">
<Link
onClick={handleClick}
href="/panel/profile"
className="bg-neo-aqua flex flex-row items-center justify-center gap-2 rounded-lg px-2 py-1 transition-all duration-200 hover:scale-101 active:scale-99"
>
<AvatarIcon className="size-4 text-white" />
<div className="h-4 w-[1.5px] bg-white" />
<p className="text-white">{ta("profile")}</p>
</Link>
<button
onClick={() => {
openModal(<LogoutModal />);
}}
className="bg-error flex flex-row items-center justify-center gap-2 rounded-lg px-2 py-1 transition-all duration-200 hover:scale-101 active:scale-99"
>
<LogoutIcon className="size-5 rotate-180 text-white" />
<p className="text-white">{ta("logout")}</p>
</button>
</div>
) : (
<div className="flex items-center gap-1 py-6">
<button className="bg-neo-aqua rounded-lg p-2">
<AvatarIcon className="size-4 text-white" />
</button>
<div className="bg-text hover:text-pwa-primary rounded-lg px-3 py-1 transition-all duration-300 hover:scale-101 active:scale-99">
<Link
href={`/auth?back_url=${pathName}`}
className="text-desktop-primary line-clamp-1 flex cursor-pointer flex-row items-center gap-2"
>
<p>{t("login")}</p>
<div className="bg-desktop-primary h-4 w-[1.5px]" />
<p>{t("register")}</p>
</Link>
</div>
</div>
)}
<SideBarComponent items={mobileMenuItems} />
</nav>
{/* Footer */}
<div className="text-text mt-auto pt-4 text-center text-[8px]">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-desktop-primary/10 mx-auto mb-3 flex w-fit items-center justify-center gap-4 rounded-2xl p-3 shadow-md"
></motion.div>
{t("policy")}
</div>
</motion.aside>
</div>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,39 @@
import { DarkModeIcon, LightModeIcon } from "@/assets";
import { useMapLayersStore } from "@/stores/useMapLayersStore";
import { AnimatePresence, motion } from "framer-motion";
import { useTheme } from "next-themes";
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const setActiveLayer = useMapLayersStore((s) => s.setActiveLayer);
function handleThemeToggle() {
if (theme === "dark") {
setActiveLayer("bright");
setTheme("light");
} else {
setActiveLayer("dark");
setTheme("dark");
}
}
return (
<AnimatePresence>
<button onClick={handleThemeToggle} className="flex cursor-pointer items-center py-2">
<motion.div
key={theme}
initial={{ rotate: -90, opacity: 0 }}
animate={{ rotate: 0, opacity: 1 }}
exit={{ rotate: 90, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeInOut" }}
>
{theme === "dark" ? (
<DarkModeIcon className="text-text size-6" />
) : (
<LightModeIcon className="text-text size-6" />
)}
</motion.div>
</button>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,13 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<AnimatePresence mode="wait">
<motion.div key="app" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
{children}
</motion.div>
</AnimatePresence>
);
}

View File

@@ -1,40 +1,23 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ThemeProvider } from "next-themes";
import { useEffect, useState } from "react";
import { useEffect, useEffectEvent, useState } from "react";
export function ClientAppProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
// wait for hydration
useEffect(() => {
const syncDevice = useEffectEvent(() => {
setMounted(true);
});
useEffect(() => {
syncDevice();
}, []);
if (!mounted) return null;
return (
<ThemeProvider attribute="data-theme" defaultTheme="dark" enableSystem>
<AnimatePresence mode="wait">
{!mounted ? (
<motion.div
key="splash"
className="bg-map-bg flex h-dvh w-dvw items-center justify-center"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
) : (
<motion.div
key="app"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="bg-map-bg"
>
{children}
</motion.div>
)}
</AnimatePresence>
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { create } from "zustand";
type SidebarState = {
isOpen: boolean;
toggle: () => void;
close: () => void;
};
export const useSidebarStore = create<SidebarState>((set) => ({
isOpen: false,
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
close: () => set({ isOpen: false }),
}));

View File

@@ -0,0 +1,21 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type LayerType = "dark" | "bright";
type MapLayersStore = {
activeLayer: LayerType;
setActiveLayer: (layer: LayerType) => void;
};
export const useMapLayersStore = create<MapLayersStore>()(
persist(
(set) => ({
activeLayer: "dark",
setActiveLayer: (layer) => set({ activeLayer: layer }),
}),
{
name: "map-theme", // نام کلید در localStorage
}
)
);