theme provider
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
39
src/components/Modals/LogoutModal.tsx
Normal file
39
src/components/Modals/LogoutModal.tsx
Normal 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;
|
||||
104
src/components/Modals/MapLauncherModal.tsx
Normal file
104
src/components/Modals/MapLauncherModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/Modals/RedirectToSignInModal.tsx
Normal file
36
src/components/Modals/RedirectToSignInModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/SideBar/SideBarContent/MenuItem.tsx
Normal file
33
src/components/SideBar/SideBarContent/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/SideBar/SideBarContent/SubMenu.tsx
Normal file
42
src/components/SideBar/SideBarContent/SubMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/SideBar/SideBarContent/index.tsx
Normal file
51
src/components/SideBar/SideBarContent/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/SideBar/index.tsx
Normal file
131
src/components/SideBar/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/ThemeToggle/index.tsx
Normal file
39
src/components/ThemeToggle/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/providers/AppShell.tsx
Normal file
13
src/providers/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/stores/SidebarStore.ts
Normal file
13
src/stores/SidebarStore.ts
Normal 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 }),
|
||||
}));
|
||||
21
src/stores/useMapLayersStore.ts
Normal file
21
src/stores/useMapLayersStore.ts
Normal 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
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user