init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
53
webapp/components/layout/Breadcrumb.tsx
Normal file
53
webapp/components/layout/Breadcrumb.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav className="flex items-center gap-[3px] h-5">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1;
|
||||
const content = (
|
||||
<span
|
||||
className={`text-sm font-medium leading-[150%] ${
|
||||
isLast
|
||||
? "text-text-primary dark:text-white font-bold"
|
||||
: "text-text-tertiary dark:text-gray-400 hover:text-text-secondary dark:hover:text-gray-300 transition-colors cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-[3px]">
|
||||
{!isLast && item.href ? (
|
||||
<Link href={item.href}>{content}</Link>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
{!isLast && (
|
||||
<Image
|
||||
src="/icons/ui/icon-chevron-right.svg"
|
||||
alt="›"
|
||||
width={14}
|
||||
height={14}
|
||||
className="flex-shrink-0 dark:invert"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
45
webapp/components/layout/LanguageSwitch.tsx
Normal file
45
webapp/components/layout/LanguageSwitch.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@heroui/react";
|
||||
|
||||
export default function LanguageSwitch() {
|
||||
const { language, setLanguage } = useApp();
|
||||
|
||||
const languages = [
|
||||
{ key: "zh", label: "中文" },
|
||||
{ key: "en", label: "English" },
|
||||
];
|
||||
|
||||
const handleSelectionChange = (key: React.Key) => {
|
||||
setLanguage(key as "zh" | "en");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown shouldBlockScroll={false}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
variant="bordered"
|
||||
className="bg-bg-surface dark:bg-gray-800 border-border-normal dark:border-gray-700 min-w-10 h-10 px-3 rounded-lg"
|
||||
>
|
||||
<span className="text-sm font-medium text-text-primary dark:text-white">
|
||||
{language === "zh" ? "中" : "EN"}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="Language selection"
|
||||
selectedKeys={new Set([language])}
|
||||
selectionMode="single"
|
||||
onSelectionChange={(keys) => {
|
||||
const key = Array.from(keys)[0];
|
||||
if (key) handleSelectionChange(key);
|
||||
}}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<DropdownItem key={lang.key}>{lang.label}</DropdownItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
19
webapp/components/layout/PageTitle.tsx
Normal file
19
webapp/components/layout/PageTitle.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
interface PageTitleProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function PageTitle({ title, subtitle }: PageTitleProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h1 className="text-heading-h2 font-bold text-text-primary dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-body-large text-text-secondary dark:text-gray-400 mt-2">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
webapp/components/layout/SectionHeader.tsx
Normal file
17
webapp/components/layout/SectionHeader.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function SectionHeader({ title, children }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<h2 className="text-text-primary dark:text-white text-heading-h3 font-bold">
|
||||
{title}
|
||||
</h2>
|
||||
{children && <div>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
webapp/components/layout/Sidebar.tsx
Normal file
244
webapp/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ScrollShadow } from "@heroui/react";
|
||||
import {
|
||||
Menu, X,
|
||||
TrendingUp, BarChart3, ArrowLeftRight, Landmark,
|
||||
ShieldCheck, Network, Sparkles, HelpCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useApp } from "@/contexts/AppContext";
|
||||
import WalletButton from "@/components/wallet/WalletButton";
|
||||
import LanguageSwitch from "./LanguageSwitch";
|
||||
import { fetchStats } from "@/lib/api/fundmarket";
|
||||
|
||||
interface NavItem {
|
||||
Icon: LucideIcon;
|
||||
label: string;
|
||||
key: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { t } = useApp();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const { data: stats = [] } = useQuery({
|
||||
queryKey: ["fundmarket-stats"],
|
||||
queryFn: fetchStats,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const tvlValue = stats[0]?.value ?? "$0";
|
||||
|
||||
const navigationItems: NavItem[] = [
|
||||
{ Icon: TrendingUp, label: t("nav.fundMarket"), key: "FundMarket", path: "/market" },
|
||||
{ Icon: BarChart3, label: t("nav.alp"), key: "ALP", path: "/alp" },
|
||||
{ Icon: ArrowLeftRight, label: t("nav.swap"), key: "Swap", path: "/swap" },
|
||||
{ Icon: Landmark, label: t("nav.lending"), key: "Lending", path: "/lending" },
|
||||
{ Icon: ShieldCheck, label: t("nav.transparency"), key: "Transparency", path: "/transparency" },
|
||||
{ Icon: Network, label: t("nav.ecosystem"), key: "Ecosystem", path: "/ecosystem" },
|
||||
{ Icon: Sparkles, label: t("nav.points"), key: "Points", path: "/points" },
|
||||
];
|
||||
|
||||
const isActive = (path: string) =>
|
||||
pathname === path || pathname.startsWith(path + "/");
|
||||
|
||||
// 桌面端导航项
|
||||
const renderDesktopNavItems = (onClick: (path: string) => void) =>
|
||||
navigationItems.map(({ Icon, label, key, path }) => {
|
||||
const active = isActive(path);
|
||||
return (
|
||||
<div key={key} className="relative group">
|
||||
{active && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-text-primary dark:bg-white rounded-r-full" />
|
||||
)}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-text-primary/0 dark:bg-white/0 group-hover:bg-text-primary/20 dark:group-hover:bg-white/20 rounded-r-full transition-all duration-300 scale-y-0 group-hover:scale-y-100 origin-center" />
|
||||
<button
|
||||
onClick={() => onClick(path)}
|
||||
className={`
|
||||
w-full h-11 px-4 rounded-xl gap-3 flex items-center relative
|
||||
transition-all duration-200
|
||||
${active
|
||||
? "bg-fill-secondary-click dark:bg-gray-700 font-bold"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className={`flex-shrink-0 transition-all duration-200 ${
|
||||
active
|
||||
? "text-text-primary dark:text-white"
|
||||
: "text-text-secondary dark:text-gray-400 group-hover:text-text-primary dark:group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
<span className={`
|
||||
text-sm leading-[150%] transition-all duration-200
|
||||
${active
|
||||
? "text-text-primary dark:text-white"
|
||||
: "text-text-secondary dark:text-gray-400 group-hover:translate-x-1"
|
||||
}
|
||||
`}>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// 移动端抽屉导航项(pill 样式)
|
||||
const renderMobileNavItems = () =>
|
||||
navigationItems.map(({ Icon, label, key, path }) => {
|
||||
const active = isActive(path);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { router.push(path); setMobileOpen(false); }}
|
||||
className={`
|
||||
w-full flex items-center gap-4 px-4 py-3.5 rounded-2xl
|
||||
transition-all duration-200 text-left
|
||||
${active
|
||||
? "bg-gray-900 dark:bg-white"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-700/60"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
className={`flex-shrink-0 ${
|
||||
active ? "text-white dark:text-gray-900" : "text-gray-400 dark:text-gray-400"
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-[15px] font-semibold leading-[150%] ${
|
||||
active ? "text-white dark:text-gray-900" : "text-gray-700 dark:text-gray-200"
|
||||
}`}>
|
||||
{label}
|
||||
</span>
|
||||
{active && (
|
||||
<div className="ml-auto w-1.5 h-1.5 rounded-full bg-white dark:bg-gray-900" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── MOBILE NAV BAR ── */}
|
||||
<nav className="md:hidden fixed top-0 left-0 right-0 z-50 h-14 bg-bg-surface dark:bg-gray-800 border-b border-border-normal dark:border-gray-700 flex items-center justify-between px-4">
|
||||
<div
|
||||
className="h-8 w-28 relative cursor-pointer"
|
||||
onClick={() => { router.push("/market"); setMobileOpen(false); }}
|
||||
>
|
||||
<Image src="/logos/logo.svg" alt="ASSETX Logo" fill className="object-contain dark:invert" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletButton compact />
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-lg border border-border-normal dark:border-gray-700 bg-bg-surface dark:bg-gray-800"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── MOBILE DRAWER BACKDROP ── */}
|
||||
<div
|
||||
className={`md:hidden fixed inset-0 z-40 bg-black/50 backdrop-blur-sm transition-opacity duration-300 ${mobileOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ── MOBILE DRAWER (右侧滑出) ── */}
|
||||
<aside
|
||||
className={`md:hidden fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-[320px] bg-white dark:bg-gray-900 flex flex-col shadow-[-12px_0_40px_rgba(0,0,0,0.15)] transition-transform duration-300 ease-out ${mobileOpen ? "translate-x-0" : "translate-x-full"}`}
|
||||
>
|
||||
{/* 顶部:Logo + 关闭按钮 */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-5 pt-12 pb-6 landscape:pt-4 landscape:pb-3">
|
||||
<div className="h-7 w-24 relative">
|
||||
<Image src="/logos/logo.svg" alt="ASSETX Logo" fill className="object-contain dark:invert" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 导航项 */}
|
||||
<ScrollShadow className="flex-1 px-3 overflow-y-auto" hideScrollBar>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{renderMobileNavItems()}
|
||||
</nav>
|
||||
</ScrollShadow>
|
||||
|
||||
{/* 底部:TVL + Language + FAQ */}
|
||||
<div className="flex-shrink-0 px-5 py-4 landscape:py-2 space-y-3 landscape:space-y-2">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-2xl px-4 py-2 landscape:py-1.5">
|
||||
<p className="text-gray-400 dark:text-gray-500 text-[11px] font-semibold uppercase tracking-wider mb-0.5">
|
||||
{t("nav.globalTVL")}
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white text-lg font-extrabold font-inter landscape:text-base">
|
||||
{tvlValue}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => { router.push("/faq"); setMobileOpen(false); }}
|
||||
className="flex items-center gap-2 h-10 px-4 rounded-xl bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm font-semibold hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<HelpCircle size={16} />
|
||||
{t("nav.faqs")}
|
||||
</button>
|
||||
<LanguageSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── DESKTOP SIDEBAR ── */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 bg-bg-surface dark:bg-gray-800 border-r border-border-normal dark:border-gray-700 flex-col h-screen w-[240px] overflow-hidden">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 px-6 py-8">
|
||||
<div className="h-10 relative cursor-pointer" onClick={() => router.push("/market")}>
|
||||
<Image src="/logos/logo.svg" alt="ASSETX Logo" fill className="object-contain dark:invert" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<ScrollShadow className="flex-1 px-4" hideScrollBar>
|
||||
<nav className="flex flex-col gap-1 py-1">
|
||||
{renderDesktopNavItems((path) => router.push(path))}
|
||||
</nav>
|
||||
</ScrollShadow>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="flex-shrink-0 px-4 py-6 border-t border-border-gray dark:border-gray-700">
|
||||
<div className="bg-bg-subtle dark:bg-gray-700 rounded-xl p-4 mb-4">
|
||||
<p className="text-text-tertiary dark:text-gray-400 text-[10px] font-medium leading-[150%] tracking-[0.01em]">
|
||||
{t("nav.globalTVL")}
|
||||
</p>
|
||||
<p className="text-text-primary dark:text-white text-lg font-extrabold leading-[150%] font-inter mt-1">
|
||||
{tvlValue}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/faq")}
|
||||
className="w-full rounded-xl flex items-center gap-3 h-11 px-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
|
||||
>
|
||||
<HelpCircle size={18} className="text-text-primary dark:text-white flex-shrink-0" />
|
||||
<span className="text-text-primary dark:text-white text-sm font-bold leading-[150%]">
|
||||
{t("nav.faqs")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
webapp/components/layout/TopBar.tsx
Normal file
34
webapp/components/layout/TopBar.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import LanguageSwitch from "./LanguageSwitch";
|
||||
import WalletButton from "@/components/wallet/WalletButton";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface TopBarProps {
|
||||
breadcrumbItems?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export default function TopBar({ breadcrumbItems }: TopBarProps) {
|
||||
return (
|
||||
<div className="hidden md:flex items-center justify-between w-full">
|
||||
{/* Left: Breadcrumb */}
|
||||
<div className="flex items-center gap-2">
|
||||
{breadcrumbItems && <Breadcrumb items={breadcrumbItems} />}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-4 pr-4">
|
||||
{/* Language Switch */}
|
||||
<LanguageSwitch />
|
||||
|
||||
{/* Wallet Button */}
|
||||
<WalletButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user