🎉 feat: initialization
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@workspace/ui/components/breadcrumb";
|
||||
import { Separator } from "@workspace/ui/components/separator";
|
||||
import { SidebarTrigger } from "@workspace/ui/components/sidebar";
|
||||
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
|
||||
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { findNavByUrl, useNavs } from "./navs";
|
||||
import TimezoneSwitch from "./timezone-switch";
|
||||
import { UserNav } from "./user-nav";
|
||||
|
||||
export function Header() {
|
||||
const pathname = useLocation({ select: (location) => location.pathname });
|
||||
const navs = useNavs();
|
||||
const items = useMemo(() => findNavByUrl(navs, pathname), [pathname]);
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex h-14 shrink-0 items-center gap-2 bg-background">
|
||||
<div className="flex flex-1 items-center gap-2 px-3">
|
||||
<SidebarTrigger />
|
||||
<Separator className="mr-2 h-4" orientation="vertical" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item?.title}>
|
||||
{index !== items.length - 1 && (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={item?.url || "/dashboard"}>{item?.title}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{index < items.length - 1 && <BreadcrumbSeparator />}
|
||||
{index === items.length - 1 && (
|
||||
<BreadcrumbPage>{item?.title}</BreadcrumbPage>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<LanguageSwitch />
|
||||
<TimezoneSwitch />
|
||||
<ThemeSwitch />
|
||||
<UserNav />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
} from "@workspace/ui/components/sidebar";
|
||||
import { getCookie } from "@workspace/ui/lib/cookies";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Header } from "@/layout/header";
|
||||
import { SidebarLeft } from "./sidebar-left";
|
||||
|
||||
export default function DashboardLayout() {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const sidebarState = getCookie("sidebar_state");
|
||||
if (sidebarState !== undefined) {
|
||||
setOpen(sidebarState === "true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={open}>
|
||||
<SidebarLeft />
|
||||
<SidebarInset className="relative flex-grow overflow-hidden">
|
||||
<Header />
|
||||
<div className="h-[calc(100vh-56px)] flex-grow gap-4 overflow-auto p-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
items?: NavItem[];
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function useNavs() {
|
||||
const { t } = useTranslation("menu");
|
||||
|
||||
const navs: NavItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t("Dashboard", "Dashboard"),
|
||||
url: "/dashboard",
|
||||
icon: "flat-color-icons:globe",
|
||||
},
|
||||
|
||||
{
|
||||
title: t("Maintenance", "Maintenance"),
|
||||
icon: "flat-color-icons:data-protection",
|
||||
items: [
|
||||
{
|
||||
title: t("Server Management", "Server Management"),
|
||||
url: "/dashboard/servers",
|
||||
icon: "flat-color-icons:data-protection",
|
||||
},
|
||||
{
|
||||
title: t("Node Management", "Node Management"),
|
||||
url: "/dashboard/nodes",
|
||||
icon: "flat-color-icons:mind-map",
|
||||
},
|
||||
{
|
||||
title: t("Subscribe Config", "Subscribe Config"),
|
||||
url: "/dashboard/subscribe",
|
||||
icon: "flat-color-icons:ruler",
|
||||
},
|
||||
{
|
||||
title: t("Product Management", "Product Management"),
|
||||
url: "/dashboard/product",
|
||||
icon: "flat-color-icons:shop",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: t("Commerce", "Commerce"),
|
||||
icon: "flat-color-icons:sales-performance",
|
||||
items: [
|
||||
{
|
||||
title: t("Order Management", "Order Management"),
|
||||
url: "/dashboard/order",
|
||||
icon: "flat-color-icons:todo-list",
|
||||
},
|
||||
{
|
||||
title: t("Coupon Management", "Coupon Management"),
|
||||
url: "/dashboard/coupon",
|
||||
icon: "flat-color-icons:bookmark",
|
||||
},
|
||||
{
|
||||
title: t("Marketing Management", "Marketing Management"),
|
||||
url: "/dashboard/marketing",
|
||||
icon: "flat-color-icons:bullish",
|
||||
},
|
||||
{
|
||||
title: t("Announcement Management", "Announcement Management"),
|
||||
url: "/dashboard/announcement",
|
||||
icon: "flat-color-icons:advertising",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: t("Users & Support", "Users & Support"),
|
||||
icon: "flat-color-icons:collaboration",
|
||||
items: [
|
||||
{
|
||||
title: t("User Management", "User Management"),
|
||||
url: "/dashboard/user",
|
||||
icon: "flat-color-icons:conference-call",
|
||||
},
|
||||
{
|
||||
title: t("Ticket Management", "Ticket Management"),
|
||||
url: "/dashboard/ticket",
|
||||
icon: "flat-color-icons:collaboration",
|
||||
},
|
||||
{
|
||||
title: t("Document Management", "Document Management"),
|
||||
url: "/dashboard/document",
|
||||
icon: "flat-color-icons:document",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
defaultOpen: false,
|
||||
title: t("System", "System"),
|
||||
icon: "flat-color-icons:services",
|
||||
items: [
|
||||
{
|
||||
title: t("System Config", "System Config"),
|
||||
url: "/dashboard/system",
|
||||
icon: "flat-color-icons:services",
|
||||
},
|
||||
{
|
||||
title: t("Auth Control", "Auth Control"),
|
||||
url: "/dashboard/auth-control",
|
||||
icon: "flat-color-icons:lock-portrait",
|
||||
},
|
||||
{
|
||||
title: t("Payment Config", "Payment Config"),
|
||||
url: "/dashboard/payment",
|
||||
icon: "flat-color-icons:currency-exchange",
|
||||
},
|
||||
{
|
||||
title: t("ADS Config", "ADS Config"),
|
||||
url: "/dashboard/ads",
|
||||
icon: "flat-color-icons:electrical-sensor",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
defaultOpen: false,
|
||||
title: t("Logs & Analytics", "Logs & Analytics"),
|
||||
icon: "flat-color-icons:statistics",
|
||||
items: [
|
||||
{
|
||||
title: t("Login", "Login"),
|
||||
url: "/dashboard/log/login",
|
||||
icon: "flat-color-icons:unlock",
|
||||
},
|
||||
{
|
||||
title: t("Register", "Register"),
|
||||
url: "/dashboard/log/register",
|
||||
icon: "flat-color-icons:contacts",
|
||||
},
|
||||
{
|
||||
title: t("Email", "Email"),
|
||||
url: "/dashboard/log/email",
|
||||
icon: "flat-color-icons:feedback",
|
||||
},
|
||||
{
|
||||
title: t("Mobile", "Mobile"),
|
||||
url: "/dashboard/log/mobile",
|
||||
icon: "flat-color-icons:sms",
|
||||
},
|
||||
{
|
||||
title: t("Subscribe", "Subscribe"),
|
||||
url: "/dashboard/log/subscribe",
|
||||
icon: "flat-color-icons:workflow",
|
||||
},
|
||||
{
|
||||
title: t("Reset Subscribe", "Reset Subscribe"),
|
||||
url: "/dashboard/log/reset-subscribe",
|
||||
icon: "flat-color-icons:refresh",
|
||||
},
|
||||
{
|
||||
title: t("Subscribe Traffic", "Subscribe Traffic"),
|
||||
url: "/dashboard/log/subscribe-traffic",
|
||||
icon: "flat-color-icons:statistics",
|
||||
},
|
||||
{
|
||||
title: t("Server Traffic", "Server Traffic"),
|
||||
url: "/dashboard/log/server-traffic",
|
||||
icon: "flat-color-icons:statistics",
|
||||
},
|
||||
{
|
||||
title: t("Traffic Details", "Traffic Details"),
|
||||
url: "/dashboard/log/traffic-details",
|
||||
icon: "flat-color-icons:combo-chart",
|
||||
},
|
||||
{
|
||||
title: t("Balance", "Balance"),
|
||||
url: "/dashboard/log/balance",
|
||||
icon: "flat-color-icons:sales-performance",
|
||||
},
|
||||
{
|
||||
title: t("Commission", "Commission"),
|
||||
url: "/dashboard/log/commission",
|
||||
icon: "flat-color-icons:debt",
|
||||
},
|
||||
{
|
||||
title: t("Gift", "Gift"),
|
||||
url: "/dashboard/log/gift",
|
||||
icon: "flat-color-icons:donate",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return navs;
|
||||
}
|
||||
|
||||
export function findNavByUrl(navs: NavItem[], url: string) {
|
||||
function matchDynamicRoute(pattern: string, path: string): boolean {
|
||||
const regexPattern = pattern
|
||||
.replace(/:[^/]+/g, "[^/]+")
|
||||
.replace(/\//g, "\\/");
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
function findNav(
|
||||
items: NavItem[],
|
||||
url: string,
|
||||
path: NavItem[] = []
|
||||
): NavItem[] {
|
||||
for (const item of items) {
|
||||
if (item.url === url || (item.url && matchDynamicRoute(item.url, url))) {
|
||||
return [...path, item];
|
||||
}
|
||||
if (item.items) {
|
||||
const result = findNav(item.items, url, [...path, item]);
|
||||
if (result.length) return result;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return findNav(navs, url);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@workspace/ui/components/hover-card";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@workspace/ui/components/sidebar";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import React, { useState } from "react";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { type NavItem, useNavs } from "./navs";
|
||||
|
||||
function hasChildren(obj: any): obj is { items: any[] } {
|
||||
return (
|
||||
obj && Array.isArray((obj as any).items) && (obj as any).items.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarLeft({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
const { common } = useGlobalStore();
|
||||
const { site } = common;
|
||||
const navs = useNavs();
|
||||
const pathname = useLocation({ select: (location) => location.pathname });
|
||||
const { state, isMobile } = useSidebar();
|
||||
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpenGroups((prev) => {
|
||||
const next: Record<string, boolean> = { ...prev };
|
||||
navs.forEach((nav) => {
|
||||
if (hasChildren(nav) && next[nav.title] === undefined) {
|
||||
next[nav.title] = nav.defaultOpen ?? true;
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [navs]);
|
||||
|
||||
const handleToggleGroup = (title: string) => {
|
||||
setOpenGroups((prev) => ({ ...prev, [title]: !prev[title] }));
|
||||
};
|
||||
|
||||
const normalize = (p: string) =>
|
||||
p.endsWith("/") && p !== "/" ? p.replace(/\/+$/, "") : p;
|
||||
const isActiveUrl = (url: string) => {
|
||||
const path = normalize(pathname);
|
||||
const target = normalize(url);
|
||||
if (target === "/dashboard") return path === target;
|
||||
if (path === target) return true;
|
||||
return path.startsWith(`${target}/`);
|
||||
};
|
||||
|
||||
const isGroupActive = (nav: NavItem) =>
|
||||
(hasChildren(nav) && nav.items?.some((i: any) => isActiveUrl(i.url))) ||
|
||||
("url" in nav && nav.url ? isActiveUrl(nav.url as string) : false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOpenGroups((prev) => {
|
||||
const next: Record<string, boolean> = { ...prev };
|
||||
navs.forEach((nav) => {
|
||||
if (hasChildren(nav) && isGroupActive(nav)) next[nav.title] = true;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [pathname, navs]);
|
||||
|
||||
const renderCollapsedFlyout = (nav: NavItem) => {
|
||||
const ParentButton = (
|
||||
<SidebarMenuButton
|
||||
aria-label={nav.title}
|
||||
className="h-8 justify-center"
|
||||
isActive={false}
|
||||
size="sm"
|
||||
>
|
||||
{"url" in nav && nav.url ? (
|
||||
<Link to={nav.url as string}>
|
||||
{"icon" in nav && (nav as any).icon ? (
|
||||
<Icon className="size-4" icon={(nav as any).icon} />
|
||||
) : null}
|
||||
</Link>
|
||||
) : "icon" in nav && (nav as any).icon ? (
|
||||
<Icon className="size-4" icon={(nav as any).icon} />
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
|
||||
if (!hasChildren(nav)) return ParentButton;
|
||||
|
||||
return (
|
||||
<HoverCard closeDelay={200} openDelay={40}>
|
||||
<HoverCardTrigger asChild>{ParentButton}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
avoidCollisions
|
||||
className="z-[9999] w-64 p-0"
|
||||
collisionPadding={8}
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
{"icon" in nav && (nav as any).icon ? (
|
||||
<Icon className="size-4" icon={(nav as any).icon} />
|
||||
) : null}
|
||||
<span className="truncate font-medium text-muted-foreground text-xs">
|
||||
{nav.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="p-1">
|
||||
{nav.items?.map((item: any) => (
|
||||
<li key={item.title}>
|
||||
<Link
|
||||
className={[
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||
isActiveUrl(item.url)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-accent/60",
|
||||
].join(" ")}
|
||||
to={item.url}
|
||||
>
|
||||
{item.icon && <Icon className="size-4" icon={item.icon} />}
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar className="border-r-0" collapsible="icon" {...props}>
|
||||
<SidebarHeader className="p-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild className="h-10" size="sm">
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-6 items-center justify-center rounded-lg">
|
||||
<img
|
||||
alt="logo"
|
||||
className="size-full"
|
||||
height={24}
|
||||
src={site.site_logo || "/favicon.svg"}
|
||||
width={24}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold text-xs">
|
||||
{site.site_name}
|
||||
</span>
|
||||
<span className="truncate text-xs opacity-70">
|
||||
{site.site_desc}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="py-2">
|
||||
<SidebarMenu>
|
||||
{!isMobile && state === "collapsed"
|
||||
? navs.map((nav) => (
|
||||
<SidebarMenuItem className="mx-auto" key={nav.title}>
|
||||
{renderCollapsedFlyout(nav)}
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
: navs.map((nav) => {
|
||||
if (hasChildren(nav)) {
|
||||
const isOpen = openGroups[nav.title] ?? false;
|
||||
return (
|
||||
<SidebarGroup className={cn("py-1")} key={nav.title}>
|
||||
<SidebarMenuButton
|
||||
className={cn(
|
||||
"mb-2 flex h-8 w-full items-center justify-between hover:bg-accent/60 hover:text-accent-foreground"
|
||||
)}
|
||||
isActive={false}
|
||||
onClick={() => handleToggleGroup(nav.title)}
|
||||
size="sm"
|
||||
style={{ fontWeight: 500 }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{"icon" in nav && (nav as any).icon ? (
|
||||
<Icon
|
||||
className="size-4 shrink-0"
|
||||
icon={(nav as any).icon}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-sm">{nav.title}</span>
|
||||
</span>
|
||||
<Icon
|
||||
className={`ml-2 size-4 transition-transform ${isOpen ? "" : "-rotate-90"}`}
|
||||
icon="mdi:chevron-down"
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
{isOpen && (
|
||||
<SidebarGroupContent className="px-4">
|
||||
<SidebarMenu>
|
||||
{nav.items?.map((item: any) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="h-8"
|
||||
isActive={isActiveUrl(item.url)}
|
||||
size="sm"
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
{item.icon && (
|
||||
<Icon
|
||||
className="size-4"
|
||||
icon={item.icon}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup className="py-1" key={nav.title}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild={"url" in nav && !!(nav as any).url}
|
||||
className="h-8"
|
||||
isActive={
|
||||
"url" in nav && (nav as any).url
|
||||
? isActiveUrl((nav as any).url)
|
||||
: false
|
||||
}
|
||||
size="sm"
|
||||
tooltip={nav.title}
|
||||
>
|
||||
{"url" in nav && (nav as any).url ? (
|
||||
<Link to={(nav as any).url}>
|
||||
{"icon" in nav && (nav as any).icon ? (
|
||||
<Icon
|
||||
className="size-4"
|
||||
icon={(nav as any).icon}
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-sm">{nav.title}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{"icon" in nav && (nav as any).icon ? (
|
||||
<Icon
|
||||
className="size-4"
|
||||
icon={(nav as any).icon}
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-sm">{nav.title}</span>
|
||||
</>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@workspace/ui/components/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@workspace/ui/components/popover";
|
||||
import { Icon } from "@workspace/ui/composed/icon";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TimezoneOption {
|
||||
value: string;
|
||||
label: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
function getCurrentTime(timezone: string): string {
|
||||
try {
|
||||
const now = new Date();
|
||||
return now.toLocaleTimeString("en-US", {
|
||||
timeZone: timezone,
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "--:--";
|
||||
}
|
||||
}
|
||||
|
||||
function getAllTimezones(locale = "en-US"): TimezoneOption[] {
|
||||
try {
|
||||
const timeZones = Intl.supportedValuesOf("timeZone");
|
||||
|
||||
const processed = timeZones
|
||||
.map((tz) => {
|
||||
try {
|
||||
return {
|
||||
value: tz,
|
||||
label: tz,
|
||||
timezone: getTimezoneOffset(tz),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
value: tz,
|
||||
label: tz,
|
||||
timezone: "UTC+00:00",
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.label.localeCompare(b.label, locale));
|
||||
|
||||
const hasUTC = processed.some((tz) => tz.value === "UTC");
|
||||
if (!hasUTC) {
|
||||
processed.unshift({
|
||||
value: "UTC",
|
||||
label: "UTC",
|
||||
timezone: "UTC+00:00",
|
||||
});
|
||||
}
|
||||
|
||||
return processed;
|
||||
} catch {
|
||||
return [
|
||||
{
|
||||
value: "UTC",
|
||||
label: "UTC",
|
||||
timezone: "UTC+00:00",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getServerTimezones(): string[] {
|
||||
return ["UTC"];
|
||||
}
|
||||
|
||||
function getRecommendedTimezones(): string[] {
|
||||
try {
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (browserTimezone.startsWith("Asia/")) {
|
||||
return [
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Singapore",
|
||||
"Asia/Seoul",
|
||||
];
|
||||
}
|
||||
if (browserTimezone.startsWith("Europe/")) {
|
||||
return [
|
||||
"Europe/London",
|
||||
"Europe/Paris",
|
||||
"Europe/Berlin",
|
||||
"Europe/Rome",
|
||||
"Europe/Madrid",
|
||||
];
|
||||
}
|
||||
if (browserTimezone.startsWith("America/")) {
|
||||
return [
|
||||
"America/New_York",
|
||||
"America/Los_Angeles",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Toronto",
|
||||
];
|
||||
}
|
||||
if (browserTimezone.startsWith("Australia/")) {
|
||||
return [
|
||||
"Australia/Sydney",
|
||||
"Australia/Melbourne",
|
||||
"Australia/Perth",
|
||||
"Australia/Brisbane",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
} catch {
|
||||
return [
|
||||
"America/New_York",
|
||||
"Europe/London",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getTimezoneOffset(timezone: string): string {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const utc = new Date(now.getTime() + now.getTimezoneOffset() * 60_000);
|
||||
const targetTime = new Date(
|
||||
utc.toLocaleString("en-US", { timeZone: timezone })
|
||||
);
|
||||
const offset = (targetTime.getTime() - utc.getTime()) / (1000 * 60 * 60);
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.floor(Math.abs(offset));
|
||||
const minutes = Math.floor((Math.abs(offset) - hours) * 60);
|
||||
|
||||
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||
} catch {
|
||||
return "UTC+00:00";
|
||||
}
|
||||
}
|
||||
|
||||
export default function TimezoneSwitch() {
|
||||
const { i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const [timezone, setTimezone] = useState<string>("UTC");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const timezoneOptions = useMemo(() => getAllTimezones(locale), [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTimezone = localStorage.getItem("timezone");
|
||||
if (savedTimezone) {
|
||||
setTimezone(savedTimezone);
|
||||
} else {
|
||||
try {
|
||||
const browserTimezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
setTimezone(browserTimezone);
|
||||
localStorage.setItem("timezone", browserTimezone);
|
||||
} catch {
|
||||
setTimezone("UTC");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimezoneChange = (newTimezone: string) => {
|
||||
setTimezone(newTimezone);
|
||||
localStorage.setItem("timezone", newTimezone);
|
||||
setOpen(false);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("timezoneChanged", {
|
||||
detail: { timezone: newTimezone },
|
||||
})
|
||||
);
|
||||
};
|
||||
const serverTimezones = timezoneOptions.filter(
|
||||
(option) =>
|
||||
getServerTimezones().includes(option.value) && option.value !== timezone
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="p-0" size="icon" variant="ghost">
|
||||
<Icon className="!size-6" icon="flat-color-icons:overtime" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search..." />
|
||||
<CommandList>
|
||||
<CommandGroup heading="Current">
|
||||
{timezoneOptions
|
||||
.filter((option) => option.value === timezone)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
className="bg-primary/10"
|
||||
key={option.value}
|
||||
onSelect={() => handleTimezoneChange(option.value)}
|
||||
value={`${option.label} ${option.value}`}
|
||||
>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-medium">{option.value}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{option.timezone} • {getCurrentTime(option.value)}
|
||||
</span>
|
||||
</div>
|
||||
<Icon className="h-4 w-4 opacity-100" icon="uil:check" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{serverTimezones.length > 0 && (
|
||||
<CommandGroup heading="Server">
|
||||
{serverTimezones.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleTimezoneChange(option.value)}
|
||||
value={`${option.label} ${option.value}`}
|
||||
>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-medium">{option.value}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{option.timezone} • {getCurrentTime(option.value)}
|
||||
</span>
|
||||
</div>
|
||||
<Icon className="h-4 w-4 opacity-0" icon="uil:check" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
<CommandGroup heading="Recommended">
|
||||
{timezoneOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
getRecommendedTimezones().includes(option.value) &&
|
||||
option.value !== timezone
|
||||
)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleTimezoneChange(option.value)}
|
||||
value={`${option.label} ${option.value}`}
|
||||
>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-medium">{option.value}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{option.timezone} • {getCurrentTime(option.value)}
|
||||
</span>
|
||||
</div>
|
||||
<Icon className="h-4 w-4 opacity-0" icon="uil:check" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandGroup heading="All">
|
||||
{timezoneOptions
|
||||
.filter(
|
||||
(option) =>
|
||||
!(
|
||||
getServerTimezones().includes(option.value) ||
|
||||
getRecommendedTimezones().includes(option.value)
|
||||
) && option.value !== timezone
|
||||
)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleTimezoneChange(option.value)}
|
||||
value={`${option.label} ${option.value}`}
|
||||
>
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-medium">{option.value}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{option.timezone} • {getCurrentTime(option.value)}
|
||||
</span>
|
||||
</div>
|
||||
<Icon className="h-4 w-4 opacity-0" icon="uil:check" />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@workspace/ui/components/avatar";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGlobalStore } from "@/stores/global";
|
||||
import { Logout } from "@/utils/common";
|
||||
|
||||
export function UserNav() {
|
||||
const { t } = useTranslation("auth");
|
||||
const { user } = useGlobalStore();
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="default">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage alt={user?.avatar ?? ""} src={user?.avatar ?? ""} />
|
||||
<AvatarFallback className="rounded-none bg-transparent">
|
||||
{user?.auth_methods?.[0]?.auth_identifier
|
||||
.toUpperCase()
|
||||
.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
{user?.auth_methods?.[0]?.auth_identifier}
|
||||
</p>
|
||||
{/* <p className='text-xs leading-none text-muted-foreground'>ID: {user?.id}</p> */}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
{/* <DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Billing
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
Settings
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>New Team</DropdownMenuItem>
|
||||
</DropdownMenuGroup> */}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={Logout}>
|
||||
{t("logout", "Logout")}
|
||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user