🎉 feat: initialization

This commit is contained in:
web
2025-11-26 19:56:16 -08:00
commit a801849fb2
553 changed files with 213088 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { Separator } from "@workspace/ui/components/separator";
import { Icon } from "@workspace/ui/composed/icon";
import { Fragment, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
interface CustomData {
community?: {
discord?: string;
facebook?: string;
github?: string;
instagram?: string;
linkedin?: string;
telegram?: string;
twitter?: string;
};
contacts?: {
address?: string;
email?: string;
telephone?: string;
};
website?: string;
}
export default function Footer() {
const { t } = useTranslation("components");
const { common } = useGlobalStore();
const { site } = common;
const customData = useMemo<CustomData>(() => {
try {
return JSON.parse(site.custom_data || "{}");
} catch {
return {};
}
}, [site.custom_data]);
const links = useMemo(
() => [
{
name: "email",
icon: "uil:envelope",
href: customData.contacts?.email
? `mailto:${customData.contacts.email}`
: undefined,
},
{
name: "telegram",
icon: "uil:telegram",
href: customData.community?.telegram,
},
{
name: "twitter",
icon: "uil:twitter",
href: customData.community?.twitter,
},
{
name: "discord",
icon: "uil:discord",
href: customData.community?.discord,
},
{
name: "instagram",
icon: "uil:instagram",
href: customData.community?.instagram,
},
{
name: "linkedin",
icon: "uil:linkedin",
href: customData.community?.linkedin,
},
{
name: "github",
icon: "uil:github",
href: customData.community?.github,
},
{
name: "facebook",
icon: "uil:facebook",
href: customData.community?.facebook,
},
],
[customData]
);
return (
<footer>
<Separator className="my-14" />
<div className="container mb-14 flex flex-wrap justify-between gap-4 text-muted-foreground text-sm">
<nav className="flex flex-wrap items-center gap-2">
{links
.filter((item) => item.href)
.map((item, index) => (
<Fragment key={index}>
{index !== 0 && <Separator orientation="vertical" />}
<a
aria-label={t(
`footer.social.${item.name}`,
`Visit our ${item.name}`
)}
href={item.href}
rel="noopener noreferrer"
target="_blank"
>
<Icon className="size-5 text-foreground" icon={item.icon} />
</a>
</Fragment>
))}
</nav>
<div>
<strong className="text-foreground">{site.site_name}</strong> ©{" "}
{t("footer.copyright", "All rights reserved")}.
<div>
<a className="underline" href="/tos">
{t("footer.tos", "Terms of Service")}
</a>
<a className="ml-2 underline" href="/privacy-policy">
{t("footer.privacyPolicy", "Privacy Policy")}
</a>
</div>
</div>
</div>
</footer>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { Link } from "@tanstack/react-router";
import { buttonVariants } from "@workspace/ui/components/button";
import { LanguageSwitch } from "@workspace/ui/composed/language-switch";
import { ThemeSwitch } from "@workspace/ui/composed/theme-switch";
import { useTranslation } from "react-i18next";
import { useGlobalStore } from "@/stores/global";
import { UserNav } from "./user-nav";
export default function Header() {
const { t } = useTranslation("components");
const { common, user } = useGlobalStore();
const { site } = common;
const Logo = (
<Link className="flex items-center gap-2 font-bold text-lg" to="/">
{site.site_logo && (
<img alt="logo" height={36} src={site.site_logo} width={36} />
)}
<span className="">{site.site_name}</span>
</Link>
);
return (
<header className="sticky top-0 z-50 border-b backdrop-blur-md">
<div className="container flex h-16 items-center justify-between">
<nav className="flex-col gap-6 font-medium text-lg md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6">
{Logo}
</nav>
<div className="flex flex-1 items-center justify-end gap-2">
<LanguageSwitch />
<ThemeSwitch />
<UserNav />
{!user && (
<Link
className={buttonVariants({
size: "sm",
})}
to="/auth"
>
{t("loginRegister", "Login / Register")}
</Link>
)}
</div>
</div>
</header>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useTranslation } from "react-i18next";
export interface NavItem {
title: string;
url: string;
icon: string;
}
export interface NavGroup {
title: string;
url?: string;
icon?: string;
items?: NavItem[];
}
export function useNavs() {
const { t } = useTranslation("components");
const navs: NavGroup[] = [
{
title: t("menu.dashboard", "Dashboard"),
url: "/dashboard",
icon: "uil:dashboard",
},
{
title: t("menu.personal", "Personal"),
items: [
{
title: t("menu.profile", "User Detail"),
url: "/profile",
icon: "uil:user",
},
],
},
{
title: t("menu.server", "Server Management"),
items: [
{
url: "/subscribe",
icon: "uil:shop",
title: t("menu.subscribe", "Subscribe"),
},
],
},
{
title: t("menu.finance", "Commerce"),
items: [
{
url: "/order",
icon: "uil:notes",
title: t("menu.order", "Order Management"),
},
{
url: "/wallet",
icon: "uil:wallet",
title: t("menu.wallet", "Balance"),
},
{
url: "/affiliate",
icon: "uil:users-alt",
title: t("menu.affiliate", "Commission"),
},
],
},
{
title: t("menu.help", "Users & Support"),
items: [
{
url: "/document",
icon: "uil:book-alt",
title: t("menu.document", "Document Management"),
},
{
url: "/announcement",
icon: "uil:megaphone",
title: t("menu.announcement", "Announcement Management"),
},
{
url: "/ticket",
icon: "uil:message",
title: t("menu.ticket", "Ticket Management"),
},
],
},
];
return navs;
}
export function useFindNavByUrl(url: string) {
const navs = useNavs();
for (const nav of navs) {
if (nav.url && nav.url === url) {
return [nav];
}
if (nav.items) {
const current = nav.items.find((item) => item.url === url);
if (current) {
return [nav, current];
}
}
}
return [];
}
export function useNavItems() {
const { t } = useTranslation("components");
return [
{
url: "/profile",
icon: "uil:user",
title: t("menu.profile", "User Detail"),
},
{
url: "/subscribe",
icon: "uil:shop",
title: t("menu.subscribe", "Subscribe"),
},
{
url: "/order",
icon: "uil:notes",
title: t("menu.order", "Order Management"),
},
{
url: "/wallet",
icon: "uil:wallet",
title: t("menu.wallet", "Balance"),
},
];
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { useNavigate } from "@tanstack/react-router";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@workspace/ui/components/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@workspace/ui/components/dropdown-menu";
import { Icon } from "@workspace/ui/composed/icon";
import { removeCookie } from "@workspace/ui/lib/cookies";
import { useTranslation } from "react-i18next";
import { useNavs } from "@/layout/navs";
import { useGlobalStore } from "@/stores/global";
export function UserNav() {
const { t } = useTranslation("components");
const { user, setUser } = useGlobalStore();
const navigate = useNavigate();
const navs = useNavs();
const handleLogout = () => {
removeCookie("Authorization");
setUser(undefined);
navigate({ to: "/" });
};
if (user) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex cursor-pointer items-center gap-2 rounded-full border bg-background px-2 py-1.5 transition-colors duration-200 hover:bg-accent">
<Avatar className="h-6 w-6">
<AvatarImage
alt={user?.avatar ?? ""}
className="object-cover"
src={user?.auth_methods?.[0]?.auth_identifier ?? ""}
/>
<AvatarFallback className="bg-gradient-to-br from-primary/90 to-primary font-medium text-background">
{user?.auth_methods?.[0]?.auth_identifier
.toUpperCase()
.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="max-w-[40px] truncate text-sm sm:max-w-[100px]">
{user?.auth_methods?.[0]?.auth_identifier.split("@")[0]}
</span>
<Icon
className="size-4 text-muted-foreground"
icon="mdi:chevron-down"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<Avatar className="h-10 w-10">
<AvatarImage
alt={user?.avatar ?? ""}
className="object-cover"
src={user?.avatar ?? ""}
/>
<AvatarFallback className="bg-gradient-to-br from-primary/90 to-primary text-background">
{user?.auth_methods?.[0]?.auth_identifier
.toUpperCase()
.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col space-y-0.5">
<p className="font-medium text-sm leading-none">
{user?.auth_methods?.[0]?.auth_identifier.split("@")[0]}
</p>
<p className="text-muted-foreground text-xs">
{user?.auth_methods?.[0]?.auth_identifier}
</p>
</div>
</div>
<DropdownMenuSeparator />
{navs.map((nav) => (
<DropdownMenuGroup key={nav.title}>
{(nav.items || [nav]).map((item) => (
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 py-2"
key={item.title}
onClick={() => {
navigate({ to: item.url });
}}
>
<Icon
className="size-4 flex-none text-muted-foreground"
icon={item.icon as string}
/>
<span className="flex-grow truncate">{item.title}</span>
<Icon
className="size-4 text-muted-foreground opacity-50"
icon="lucide:chevron-right"
/>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 py-2 text-destructive focus:text-destructive"
onClick={handleLogout}
>
<Icon className="size-4 flex-none" icon="uil:exit" />
<span className="flex-grow">{t("menu.logout", "Logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}