diff --git a/package.json b/package.json index 2dba20f..541fa5f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "github-markdown-css": "^5.9.0", "js-md5": "^0.8.3", "jszip": "^3.10.1", + "lucide-react": "^1.19.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-helmet-async": "^2.0.5", @@ -49,4 +50,4 @@ "vite": "^7.3.1", "vitest": "^4.1.7" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9682079..ed0b2ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + lucide-react: + specifier: ^1.19.0 + version: 1.20.0(react@19.2.4) react: specifier: ^19.2.4 version: 19.2.4 @@ -758,79 +761,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -903,28 +893,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -1695,28 +1681,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1755,6 +1737,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.20.0: + resolution: {integrity: sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3939,6 +3926,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.20.0(react@19.2.4): + dependencies: + react: 19.2.4 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/public/i18n/en.json b/public/i18n/en.json index 5f52761..3bdb5a7 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -374,6 +374,7 @@ "CreateSuccess": "Create successful", "CreateFailed": "Create failed", "Submit": "Submit", + "Tools": "Tools", "Manage": "Manage", "ExitManage": "Exit Manage", "AllCharts": "All Charts", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 1f17712..8d11d4c 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -373,5 +373,6 @@ "CreateSuccess": "作成に成功しました", "CreateFailed": "作成に失敗しました", "Submit": "送信", + "Tools": "ツール", "500UploadErrorAndHint": "アップロードに失敗しました。主サイトに行ってアップロードしてください" } diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 72d5881..e0945b3 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -373,5 +373,6 @@ "CreateSuccess": "생성 성공", "CreateFailed": "생성 실패", "Submit": "제출", + "Tools": "도구", "500UploadErrorAndHint": "업로드 실패, 주 사이트로 가서 업로드하세요" } diff --git a/public/i18n/zh.json b/public/i18n/zh.json index ba25172..ef46a34 100644 --- a/public/i18n/zh.json +++ b/public/i18n/zh.json @@ -8,7 +8,7 @@ "Collection": "收藏", "ClipboardSuccess": "已复制到剪贴板", "Comment": "评论", - "CommentCount": "评", + "CommentCount": "评论数", "Download": "下载", "Share": "分享", "EmptyComment": "说点什么吧?", @@ -19,7 +19,7 @@ "JoinAt": "注册于", "LastPage": "上一页", "Like": "点赞", - "LikeCount": "赞", + "LikeCount": "点赞数", "Login": "登录", "Logout": "登出", "MajdataPunchline": "Majdata是使用Simai格式的开源maimai自制谱编辑器。", @@ -32,7 +32,7 @@ "NoTags": "暂无标签", "PersonalHomePage": "个人主页", "Play": "游玩", - "PlayCount": "播", + "PlayCount": "播放数", "Post": "发表", "Preview": "预览", "PreviewPlaceholder": "预览区域 - 在上方输入内容后点击预览查看效果", @@ -54,7 +54,7 @@ "Test": "测试", "Upload": "上传", "UploadChart": "谱面上传", - "UploadDate": "序", + "UploadDate": "上传日期", "UploadNoticeTerms1": "上传的谱面是你自己写的或合作写的,又或是谱面原作者同意上传;", "UploadNoticeTerms2": "此谱面可以公开,自由下载;", "UploadNoticeTerms3": "公开这张谱面不会侵害第三方权益;", @@ -383,5 +383,6 @@ "CreateSuccess": "创建成功", "CreateFailed": "创建失败", "Submit": "提交", + "Tools": "工具", "500UploadErrorAndHint": "上传失败, 请前往主站上传" } diff --git a/public/icons/now_loading.png b/public/icons/now_loading.png new file mode 100644 index 0000000..6354ae6 Binary files /dev/null and b/public/icons/now_loading.png differ diff --git a/src/components/collection/CollectionCard.tsx b/src/components/collection/CollectionCard.tsx index 48d2869..54c5487 100644 --- a/src/components/collection/CollectionCard.tsx +++ b/src/components/collection/CollectionCard.tsx @@ -1,14 +1,17 @@ -import { useState, useEffect, memo } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { toast } from 'react-toastify'; -import { motion, AnimatePresence } from 'framer-motion'; -import useSWR from 'swr'; -import { LazyLoad, CoverPic, LoadingSpinner } from '@/components'; -import { endpoints } from '@/config/api'; -import type { Collection, CollectionSongList } from '@/types'; -import { useLoc, useFavorites } from '@/hooks'; - -const fetcher = (url: string) => fetch(url, { mode: 'cors', credentials: 'include' }).then((res) => res.json()); +import { useState, useEffect, memo } from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import { motion, AnimatePresence } from "framer-motion"; +import useSWR from "swr"; +import { LazyLoad, CoverPic, LoadingSpinner } from "@/components"; +import { endpoints } from "@/config/api"; +import type { Collection, CollectionSongList } from "@/types"; +import { useLoc, useFavorites } from "@/hooks"; + +const fetcher = (url: string) => + fetch(url, { mode: "cors", credentials: "include" }).then((res) => + res.json(), + ); interface CollectionCardProps { collection: Collection; @@ -16,184 +19,258 @@ interface CollectionCardProps { onDelete?: () => void; } -const CollectionCardContent = memo(({ collection, isManaging, onDelete }: CollectionCardProps) => { - const loc = useLoc(); - const navigate = useNavigate(); - const { favoriteIds, toggleFavorite, isPending } = useFavorites(); - - const url = endpoints.collection.songlist(collection.id); - const { data, isLoading } = useSWR(url, fetcher, { revalidateOnFocus: false }); - - const isFavorited = favoriteIds.has(collection.id); - const isFavoriteLoading = isPending(collection.id); - - const [currentCoverIndex, setCurrentCoverIndex] = useState(0); - const [confirmDelete, setConfirmDelete] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const [isHovered, setIsHovered] = useState(false); - - useEffect(() => { - if (!isHovered) return; - if (!data || !data.items || data.items.length <= 1) return; - - const timer = setInterval(() => { - setCurrentCoverIndex((prev) => (prev + 1) % data.items.length); - }, 1000); - - return () => clearInterval(timer); - }, [data, isHovered]); - - const handleDelete = async () => { - setIsDeleting(true); - try { - const res = await fetch(endpoints.collection.destroy(collection.id), { - method: 'POST', - mode: 'cors', - credentials: 'include', - }); - if (res.ok) { - toast.success(loc('DeleteSuccess', '删除成功')); - onDelete?.(); - } else { - toast.error(loc('DeleteFailed', '删除失败')); +const CollectionCardContent = memo( + ({ collection, isManaging, onDelete }: CollectionCardProps) => { + const loc = useLoc(); + const navigate = useNavigate(); + const { favoriteIds, toggleFavorite, isPending } = useFavorites(); + + const url = endpoints.collection.songlist(collection.id); + const { data, isLoading } = useSWR(url, fetcher, { + revalidateOnFocus: false, + }); + + const isFavorited = favoriteIds.has(collection.id); + const isFavoriteLoading = isPending(collection.id); + + const [currentCoverIndex, setCurrentCoverIndex] = useState(0); + const [confirmDelete, setConfirmDelete] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + if (!isHovered) return; + if (!data || !data.items || data.items.length <= 1) return; + + const timer = setInterval(() => { + setCurrentCoverIndex((prev) => (prev + 1) % data.items.length); + }, 1000); + + return () => clearInterval(timer); + }, [data, isHovered]); + + const handleDelete = async () => { + setIsDeleting(true); + try { + const res = await fetch(endpoints.collection.destroy(collection.id), { + method: "POST", + mode: "cors", + credentials: "include", + }); + if (res.ok) { + toast.success(loc("DeleteSuccess", "删除成功")); + onDelete?.(); + } else { + toast.error(loc("DeleteFailed", "删除失败")); + } + } catch { + toast.error(loc("DeleteFailed", "删除失败")); + } finally { + setIsDeleting(false); } - } catch { - toast.error(loc('DeleteFailed', '删除失败')); - } finally { - setIsDeleting(false); - } - }; + }; - return ( -
-
setIsHovered(true)} - onMouseLeave={() => { setIsHovered(false); setCurrentCoverIndex(0); }} - onClick={() => { - if (isManaging) return; - navigate('/collection?id=' + collection.id); - }}> - - {/* Left side: Cover */} -
- {isLoading ? ( -
- + return ( +
+
setIsHovered(true)} + onMouseLeave={() => { + setIsHovered(false); + setCurrentCoverIndex(0); + }} + onClick={() => { + if (isManaging) return; + navigate("/collection?id=" + collection.id); + }} + > + {/* Left side: Cover */} +
+ {isLoading ? ( +
+ +
+ ) : data && data.items && data.items.length > 0 ? ( + + ) : ( +
+ {loc("NoCover", "无封面")} +
+ )} +
+ + {/* Right side: Info */} +
+
+ {collection.name}
- ) : data && data.items && data.items.length > 0 ? ( - - ) : ( -
- {loc('NoCover', '无封面')} +
+ {collection.description || loc("NoDescription", "暂无描述")} +
+
+ {loc("CreatorLabel", "创建者")}: {collection.createdBy} +
+
+ {loc("CountLabel", "数量")}: {collection.count}
- )} -
- - {/* Right side: Info */} -
-
{collection.name}
-
- {collection.description || loc('NoDescription', '暂无描述')}
-
{loc('CreatorLabel', '创建者')}: {collection.createdBy}
-
{loc('CountLabel', '数量')}: {collection.count}
-
- - {/* Favorite button */} - {!isManaging && ( - { - e.stopPropagation(); - toggleFavorite(collection.id); - }} - disabled={isFavoriteLoading} - className={`absolute top-2 right-2 z-10 flex justify-center items-center border-none rounded-full w-7 h-7 text-sm cursor-pointer shadow-lg backdrop-blur-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${isFavorited - ? 'bg-pink-500/70 hover:bg-pink-500 text-white' - : 'bg-black/30 hover:bg-black/50 text-white/60 hover:text-white' - }`} - title={isFavorited ? loc('Unfavorite', '取消收藏') : loc('Favorite', '收藏')} - > - {isFavoriteLoading ? ( - - ) : isFavorited ? ( - - ) : ( - - )} - - )} - {/* Delete button (management mode) */} - - {isManaging && !confirmDelete && ( + {/* Favorite button */} + {!isManaging && ( { e.stopPropagation(); - setConfirmDelete(true); + toggleFavorite(collection.id); }} - className="top-2 right-2 z-10 absolute flex justify-center items-center bg-red-500/70 hover:bg-red-500 shadow-lg backdrop-blur-sm border-none rounded-full w-7 h-7 text-white text-sm transition-colors cursor-pointer" - title={loc('Delete', '删除')} - > - - - )} - - - {/* Delete confirmation overlay */} - - {confirmDelete && ( - -

{loc('ConfirmDeleteCollection', '确认删除此歌单?')}

-
- - -
-
+ + + )} + )} -
-
- ); -}); -export default function CollectionCard({ collection, isManaging, onDelete }: CollectionCardProps) { + {/* Delete button (management mode) */} + + {isManaging && !confirmDelete && ( + { + e.stopPropagation(); + setConfirmDelete(true); + }} + className="top-2 right-2 z-10 absolute flex justify-center items-center bg-red-500/70 hover:bg-red-500 shadow-lg backdrop-blur-sm border-none rounded-full w-7 h-7 text-white text-sm transition-colors cursor-pointer" + title={loc("Delete", "删除")} + > + + + + + + + + + )} + + + {/* Delete confirmation overlay */} + + {confirmDelete && ( + +

+ {loc("ConfirmDeleteCollection", "确认删除此歌单?")} +

+
+ + +
+
+ )} +
+
+ ); + }, +); + +export default function CollectionCard({ + collection, + isManaging, + onDelete, +}: CollectionCardProps) { return ( - + ); } diff --git a/src/components/header/AuthSection.tsx b/src/components/header/AuthSection.tsx index 9844990..004e995 100644 --- a/src/components/header/AuthSection.tsx +++ b/src/components/header/AuthSection.tsx @@ -1,9 +1,13 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { AiOutlineUser } from 'react-icons/ai'; -import { useLoc } from '@/hooks'; -import { HAMBURGER_BUTTON_BASE, HAMBURGER_BUTTON_ACTIVE, HAMBURGER_BUTTON_HOVER } from './styles'; -import Dropdown from './Dropdown'; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { AiOutlineUser } from "react-icons/ai"; +import { useLoc } from "@/hooks"; +import { + HAMBURGER_BUTTON_BASE, + HAMBURGER_BUTTON_ACTIVE, + HAMBURGER_BUTTON_HOVER, +} from "./styles"; +import Dropdown from "./Dropdown"; /** * 登录/注册按钮组件(未登录状态) @@ -14,13 +18,16 @@ export default function AuthSection() { return (
- {/* 桌面端:传统链接形式 */} -
- - {loc('Login')} - - - {loc('Register')} + {/* 桌面端:合并登录/注册按钮 */} +
+ + + + {loc("Login")} / {loc("Register")} +
@@ -30,16 +37,23 @@ export default function AuthSection() { className={`${HAMBURGER_BUTTON_BASE} ${isMobileAuthMenuOpen ? HAMBURGER_BUTTON_ACTIVE : HAMBURGER_BUTTON_HOVER}`} onClick={() => setIsMobileAuthMenuOpen(!isMobileAuthMenuOpen)} > - - + + - setIsMobileAuthMenuOpen(false)} position="right" className="p-4"> - - {loc('Login')} - - - {loc('Register')} + setIsMobileAuthMenuOpen(false)} + className="p-4" + > + + + + {loc("Login")} / {loc("Register")} +
diff --git a/src/components/header/DesktopNav.tsx b/src/components/header/DesktopNav.tsx index da700f5..e002b01 100644 --- a/src/components/header/DesktopNav.tsx +++ b/src/components/header/DesktopNav.tsx @@ -1,83 +1,198 @@ -import { useRef, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { useLoc } from '@/hooks'; -import Dropdown from './Dropdown'; -import { DROPDOWN_ITEM, NAV_LINK } from './styles'; - +import { useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { useLoc } from "@/hooks"; +import Dropdown from "./Dropdown"; +import { DROPDOWN_ITEM, NAV_LINK, DESKTOP_DROPDOWN_ITEM } from "./styles"; +import { + Birdhouse, + Podium, + ChevronDown, + Wrench, + BookOpenText, + LandPlot, + Fan, + Music4, + TrendingUp, + Users, + Trophy, + PenLine, + Gamepad2, +} from "lucide-react"; /** * 桌面端导航栏 */ export default function DesktopNav() { const loc = useLoc(); + const { pathname } = useLocation(); const [isRankingsOpen, setIsRankingsOpen] = useState(false); const rankingsContainerRef = useRef(null); const [isToolsOpen, setIsToolsOpen] = useState(false); const toolsContainerRef = useRef(null); + const isRankingsActive = pathname.startsWith("/ranking"); + const isToolsActive = + pathname.startsWith("/edit") || pathname.startsWith("/play"); + return ( -
+
+ + + + + Home + + +
- setIsRankingsOpen(false)} position="left" className="w-full" containerRef={rankingsContainerRef}> - setIsRankingsOpen(false)}> - {loc('Recommend')} + setIsRankingsOpen(false)} + className="w-full" + containerRef={rankingsContainerRef} + > + setIsRankingsOpen(false)} + > + + + {loc("Recommend")} + - setIsRankingsOpen(false)}> - {loc('UserRankingTitle')} + setIsRankingsOpen(false)} + > + + + {loc("UserRankingTitle")} + - setIsRankingsOpen(false)}> - {loc('MMFCRanking')} + setIsRankingsOpen(false)} + > + + + {loc("MMFCRanking")} +
setIsToolsOpen(false)} - position="left" className="w-full" containerRef={toolsContainerRef} > - setIsToolsOpen(false)}> - {loc('ChartEditor')} + setIsToolsOpen(false)} + > + + + {loc("ChartEditor")} + - setIsToolsOpen(false)}> - MajdataPlay + setIsToolsOpen(false)} + > + + + MajdataPlay +
- - 文档 + + + + + 文档 + + - - {loc('CollectionHiroba')} + + + + + {loc("CollectionHiroba")} + + - - {loc('Contest')} + + + + + {loc("Contest")} + + - - {loc('OriginalSongs')} + + + + + {loc("OriginalSongs")} + +
); diff --git a/src/components/header/Dropdown.tsx b/src/components/header/Dropdown.tsx index 34b4c06..aab0b19 100644 --- a/src/components/header/Dropdown.tsx +++ b/src/components/header/Dropdown.tsx @@ -1,49 +1,132 @@ -import { useEffect, useRef } from 'react'; -import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; interface DropdownProps { isOpen: boolean; onClose: () => void; children: ReactNode; - position?: 'left' | 'right'; + variant?: "desktop" | "mobile" | "user" | "auto"; className?: string; containerRef?: React.RefObject; } +const DESKTOP_BREAKPOINT = 1280; + +type VariantStyle = { + position: string; + open: string; + closed: string; + base: string; +}; + +const variantStyles: Record = { + desktop: { + position: "fixed left-0 top-0 bottom-0 z-1001", + open: "translate-x-0", + closed: "-translate-x-full", + base: "bg-[#5C8DC1] !w-64 h-full transition-transform duration-300 ease-out flex flex-col items-center justify-center", + }, + mobile: { + position: "absolute right-0 top-full mt-2 z-1001", + open: "opacity-100 scale-100", + closed: "opacity-0 scale-95", + base: "bg-white shadow-lg border border-gray-200 rounded-xl min-w-48 transition-all duration-200 ease-out origin-bottom-right", + }, + user: { + position: "absolute right-0 bottom-full mb-2 z-1001", + open: "opacity-100 scale-100", + closed: "opacity-0 scale-95", + base: "bg-white shadow-lg border border-gray-200 rounded-xl min-w-48 transition-all duration-200 ease-out origin-bottom-left", + }, +}; + +function getStyle(variant: string, isDesktop: boolean): VariantStyle { + if (variant === "user") { + return isDesktop ? variantStyles.user : variantStyles.mobile; + } + return variantStyles[variant] || variantStyles.mobile; +} + /** * 通用下拉菜单组件 - * 处理点击外部关闭逻辑,无动效 + * 桌面端显示为侧边滑出面板,移动端为弹出菜单 */ -export default function Dropdown({ isOpen, onClose, children, position = 'left', className = '', containerRef }: DropdownProps) { +export default function Dropdown({ + isOpen, + onClose, + children, + variant = "auto", + className = "", + containerRef, +}: DropdownProps) { const menuRef = useRef(null); + const [render, setRender] = useState(false); + const [animateIn, setAnimateIn] = useState(false); + const [isDesktop, setIsDesktop] = useState( + typeof window !== "undefined" && window.innerWidth >= DESKTOP_BREAKPOINT, + ); + + useEffect(() => { + const check = () => setIsDesktop(window.innerWidth >= DESKTOP_BREAKPOINT); + window.addEventListener("resize", check); + return () => window.removeEventListener("resize", check); + }, []); + + useEffect(() => { + if (isOpen) { + setRender(true); + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimateIn(true)); + }); + } else { + setAnimateIn(false); + const timer = setTimeout(() => setRender(false), 300); + return () => clearTimeout(timer); + } + }, [isOpen]); useEffect(() => { if (!isOpen) return; function handleClickOutside(event: MouseEvent) { const target = event.target as Node; - const isOutsideMenu = menuRef.current && !menuRef.current.contains(target); - const isOutsideContainer = containerRef?.current && !containerRef.current.contains(target); - - // 只有当点击既在菜单外部,又在容器外部时才关闭 + const isOutsideMenu = + menuRef.current && !menuRef.current.contains(target); + const isOutsideContainer = + containerRef?.current && !containerRef.current.contains(target); + if (isOutsideMenu && isOutsideContainer) { onClose(); } } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen, onClose, containerRef]); - if (!isOpen) return null; + if (!render) return null; - const positionClass = position === 'right' ? 'right-0' : 'left-0'; + const resolved = + variant === "auto" ? (isDesktop ? "desktop" : "mobile") : variant; + const style = getStyle(resolved, isDesktop); return ( -
+ {resolved === "desktop" && ( + + + + )} {children}
); diff --git a/src/components/header/MajdataLogo.tsx b/src/components/header/MajdataLogo.tsx index 6bd381a..e56c992 100644 --- a/src/components/header/MajdataLogo.tsx +++ b/src/components/header/MajdataLogo.tsx @@ -1,5 +1,5 @@ -import { toast } from 'react-toastify'; -import { useLoc } from '@/hooks'; +import { toast } from "react-toastify"; +import { useLoc } from "@/hooks"; /** * Majdata.Net Logo 组件 @@ -7,23 +7,22 @@ import { useLoc } from '@/hooks'; */ export default function MajdataLogo() { const loc = useLoc(); - + const handleClick = () => { - toast.error(loc('FUCKYOU', 'FUCK YOU'), { - position: 'top-center', + toast.error(loc("FUCKYOU", "FUCK YOU"), { + position: "top-center", autoClose: 500, }); }; return ( -

+
xxlb - Majdata.Net -

+
); } diff --git a/src/components/header/MobileNav.tsx b/src/components/header/MobileNav.tsx index 06439e7..e555ec9 100644 --- a/src/components/header/MobileNav.tsx +++ b/src/components/header/MobileNav.tsx @@ -1,8 +1,14 @@ -import { useState, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { useLoc } from '@/hooks'; -import { MOBILE_DROPDOWN_ITEM, HAMBURGER_BUTTON_BASE, HAMBURGER_BUTTON_ACTIVE, HAMBURGER_BUTTON_HOVER } from './styles'; -import Dropdown from './Dropdown'; +import { useState, useRef } from "react"; +import { Link } from "react-router-dom"; +import { Menu, X } from "lucide-react"; +import { useLoc } from "@/hooks"; +import { + MOBILE_DROPDOWN_ITEM, + HAMBURGER_BUTTON_BASE, + HAMBURGER_BUTTON_ACTIVE, + HAMBURGER_BUTTON_HOVER, +} from "./styles"; +import Dropdown from "./Dropdown"; /** * 移动端汉堡菜单导航 @@ -20,37 +26,64 @@ export default function MobileNav() { className={`${HAMBURGER_BUTTON_BASE} ${isMainNavOpen ? HAMBURGER_BUTTON_ACTIVE : HAMBURGER_BUTTON_HOVER}`} onClick={() => setIsMainNavOpen(!isMainNavOpen)} > - - - - - + {isMainNavOpen ? ( + + ) : ( + + )} - { setIsMainNavOpen(false); setIsMobileRankingsOpen(false); setIsMobileToolsOpen(false); }} position="left" containerRef={containerRef}> + { + setIsMainNavOpen(false); + setIsMobileRankingsOpen(false); + setIsMobileToolsOpen(false); + }} + + containerRef={containerRef} + > {/* 榜单项 - 可展开 */}
{isMobileRankingsOpen && ( -
- - {loc('Recommend')} +
+ + {loc("Recommend")} - - {loc('UserRankingTitle')} + + + {loc("UserRankingTitle")} + - - {loc('MMFCRanking')} + + {loc("MMFCRanking")}
)} @@ -59,39 +92,59 @@ export default function MobileNav() { {/* 工具项 - 可展开 */}
{isMobileToolsOpen && ( -
- - {loc('ChartEditor')} +
+ + {loc("ChartEditor")} - + MajdataPlay
)}
- + 文档 - {loc('CollectionHiroba')} + + {loc("CollectionHiroba")} + - {loc('Contest')} + {loc("Contest")} - {loc('OriginalSongs')} + + {loc("OriginalSongs")} + diff --git a/src/components/header/UserMenu.tsx b/src/components/header/UserMenu.tsx index cbfede6..f80e2b8 100644 --- a/src/components/header/UserMenu.tsx +++ b/src/components/header/UserMenu.tsx @@ -1,10 +1,18 @@ -import { useState, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { useLoc } from '@/hooks'; -import { handleLogout as logoutUtil } from '@/utils'; -import { endpoints } from '@/config/api'; -import { DIVIDER } from './styles'; -import Dropdown from './Dropdown'; +import { useState, useRef } from "react"; +import { Link } from "react-router-dom"; +import { useLoc } from "@/hooks"; +import { + User, + BarChart3, + Music, + FolderHeart, + Settings, + LogOut, +} from "lucide-react"; +import { handleLogout as logoutUtil } from "@/utils"; +import { endpoints } from "@/config/api"; +import { DIVIDER, DESKTOP_DROPDOWN_ITEM } from "./styles"; +import Dropdown from "./Dropdown"; interface UserMenuProps { username: string; @@ -22,53 +30,84 @@ export default function UserMenu({ username }: UserMenuProps) { await logoutUtil( () => { setIsOpen(false); - window.location.href = '/'; + window.location.href = "/"; }, (error) => { - console.error(loc('LogoutFailed'), error); + console.error(loc("LogoutFailed"), error); setIsOpen(false); - window.location.href = '/'; - } + window.location.href = "/"; + }, ); }; return (
- setIsOpen(false)} position="right" className="w-full" containerRef={containerRef}> - - {loc('PersonalHomePage')} + setIsOpen(false)} + variant="auto" + className="w-full" + containerRef={containerRef} + > +
+ {username} + {username} +
+ + + + {loc("PersonalHomePage")} + - - {loc('PersonalScores')} + + + + {loc("PersonalScores")} + - - {loc('ChartsManagement')} + + + + {loc("ChartsManagement")} + - - {loc('MyCollections', '我的歌单')} + + + + {loc("MyCollections", "我的歌单")} + - - {loc('AccountSetting')} + + + + {loc("AccountSetting")} +
-
diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index 3a3d842..35c27df 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -1,60 +1,82 @@ -import { Link } from 'react-router-dom'; -import { useLoc, useUserContext } from '@/hooks'; -import { LoadingSpinner } from '@/components'; -import MajdataLogo from './MajdataLogo'; -import DesktopNav from './DesktopNav'; -import MobileNav from './MobileNav'; -import UserMenu from './UserMenu'; -import AuthSection from './AuthSection'; +import { Link } from "react-router-dom"; +import { useUserContext } from "@/hooks"; +import { LoadingSpinner } from "@/components"; +import MajdataLogo from "./MajdataLogo"; +import DesktopNav from "./DesktopNav"; +import MobileNav from "./MobileNav"; +import UserMenu from "./UserMenu"; +import AuthSection from "./AuthSection"; /** * 统一顶部导航栏组件 * 包含Logo、导航菜单、用户下拉菜单、登录/注册 */ export default function UnifiedHeader() { - const loc = useLoc(); const { user, isLoading, error } = useUserContext(); - const username = user?.username || ''; + const username = user?.username || ""; const isLoggedIn = !!username && !error; return ( -
-
- {/* 左侧区域:Logo + 导航 */} -
- {/* Logo Section */} -
- - - + <> + {/* 移动端:顶部横条 */} +
+
+ + xxlb + +
+ {isLoading ? ( + + ) : isLoggedIn ? ( + + ) : ( + + )} + +
+
+
+ + {/* 桌面端:左侧竖栏 */} +
+
+ {/* 背景 SVG */} + + + + {/* Logo */} +
+
- {/* Main Navigation */} + {/* Navigation */} - -
- {/* 移动端中间Logo */} -
- - xxlb - -
+ {/* Spacer */} +
- {/* User Section */} -
- {isLoading ? ( -
- - {loc('Loading')} -
- ) : isLoggedIn ? ( - - ) : ( - - )} + {/* User Section */} +
+ {isLoading ? ( +
+ +
+ ) : isLoggedIn ? ( + + ) : ( + + )} +
-
-
+
+ ); } diff --git a/src/components/header/styles.ts b/src/components/header/styles.ts index d5cbc4a..4938582 100644 --- a/src/components/header/styles.ts +++ b/src/components/header/styles.ts @@ -3,23 +3,34 @@ */ // 通用按钮样式 -export const BUTTON_BASE = "flex items-center gap-2 px-4 py-3 rounded-lg font-medium text-sm no-underline"; +export const BUTTON_BASE = + "flex items-center gap-2 px-4 py-3 rounded-lg font-medium text-sm no-underline"; // 导航链接样式 -export const NAV_LINK = "relative flex items-center gap-2 hover:bg-linear-to-br hover:from-white/12 hover:to-white/8 hover:shadow-[0_4px_12px_rgb(255_255_255/10%),0_1px_0_rgb(255_255_255/10%)_inset] px-5 py-3 rounded-lg font-medium text-white/85 hover:text-white text-sm no-underline whitespace-nowrap"; +export const NAV_LINK = + "flex items-center gap-2 w-full px-4 py-3 font-medium text-gray-600 hover:text-[#5C8DC1] text-sm no-underline whitespace-nowrap transition-all duration-300 hover:scale-105 active:scale-95"; // 下拉菜单项样式 -export const DROPDOWN_ITEM = "flex justify-center items-center gap-3 hover:bg-linear-to-br hover:from-white/12 hover:to-white/8 hover:shadow-[0_2px_8px_rgb(255_255_255/10%),0_1px_0_rgb(255_255_255/10%)_inset] px-5 py-4 font-medium text-[#e5e5e5] hover:text-white text-sm text-center no-underline"; +export const DROPDOWN_ITEM = + "flex items-center gap-3 hover:bg-linear-to-br hover:from-[#5C8DC1]/10 hover:to-[#5C8DC1]/5 hover:shadow-[0_2px_8px_rgb(92_141_193/15%),0_1px_0_rgb(92_141_193/10%)_inset] px-5 py-4 font-medium text-gray-500 hover:text-[#5C8DC1] text-sm text-left no-underline"; + +// 桌面侧边栏下拉菜单项样式 +export const DESKTOP_DROPDOWN_ITEM = + "flex flex-col items-center gap-1 px-3 py-3 no-underline font-medium text-sm relative bg-none cursor-pointer transition-all duration-300 hover:scale-105 active:scale-95 group w-full"; // 汉堡菜单按钮样式 -export const HAMBURGER_BUTTON_BASE = "flex items-center justify-center gap-2 px-3 md:px-4 bg-white/5 border border-white/10 rounded-[10px] cursor-pointer text-[#e5e5e5] text-sm font-medium h-10 md:h-12 backdrop-blur-[10px]"; +export const HAMBURGER_BUTTON_BASE = + "flex items-center justify-center gap-2 px-3 md:px-4 rounded-[10px] cursor-pointer text-gray-600 text-sm font-medium h-10 md:h-12 backdrop-blur-[10px]"; -export const HAMBURGER_BUTTON_ACTIVE = "bg-linear-to-br from-white/15 to-white/10 border-white/30 shadow-[0_8px_25px_rgb(0_0_0/25%),0_1px_0_rgb(255_255_255/10%)_inset]"; +export const HAMBURGER_BUTTON_ACTIVE = + "bg-linear-to-br from-[#5C8DC1]/15 to-[#5C8DC1]/8 border-[#5C8DC1]/30 shadow-[0_8px_25px_rgb(0_0_0/10%),0_1px_0_rgb(92_141_193/15%)_inset]"; -export const HAMBURGER_BUTTON_HOVER = "hover:bg-linear-to-br hover:from-white/15 hover:to-white/10 hover:border-white/30 hover:shadow-[0_8px_25px_rgb(0_0_0/25%),0_1px_0_rgb(255_255_255/10%)_inset]"; +export const HAMBURGER_BUTTON_HOVER = + "hover:bg-linear-to-br hover:from-[#5C8DC1]/10 hover:to-[#5C8DC1]/5 hover:border-[#5C8DC1]/20 hover:shadow-[0_8px_25px_rgb(0_0_0/8%),0_1px_0_rgb(92_141_193/10%)_inset]"; // 移动端下拉菜单项样式 -export const MOBILE_DROPDOWN_ITEM = "flex justify-center items-center gap-3 hover:bg-linear-to-br hover:from-white/12 hover:to-white/8 hover:shadow-[0_2px_8px_rgb(255_255_255/10%),0_1px_0_rgb(255_255_255/10%)_inset] px-5 py-4 font-medium text-[#e5e5e5] hover:text-white text-sm text-center no-underline"; +export const MOBILE_DROPDOWN_ITEM = + "flex items-center gap-3 hover:bg-linear-to-br hover:from-[#5C8DC1]/10 hover:to-[#5C8DC1]/5 hover:shadow-[0_2px_8px_rgb(92_141_193/15%),0_1px_0_rgb(92_141_193/10%)_inset] px-5 py-4 font-medium text-gray-500 hover:text-[#5C8DC1] text-sm text-left no-underline"; // 分隔线样式 -export const DIVIDER = "bg-white/10 my-2 h-px"; +export const DIVIDER = "bg-black/8 my-2 h-px"; diff --git a/src/components/layout/PageLayout.tsx b/src/components/layout/PageLayout.tsx index 8f41608..44df28b 100644 --- a/src/components/layout/PageLayout.tsx +++ b/src/components/layout/PageLayout.tsx @@ -1,21 +1,20 @@ -import { Link } from 'react-router-dom'; -import { ToastContainer } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; -import { motion } from 'framer-motion'; -import { loc } from '@/utils'; -import { UnifiedHeader } from '@/components'; -import AmbientBackground from './AmbientBackground'; -import FloatingButtons from './FloatingButtons'; -import type { PageLayoutProps } from '@/types'; - +import { Link } from "react-router-dom"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import { motion } from "framer-motion"; +import { loc } from "@/utils"; +import { UnifiedHeader } from "@/components"; +import AmbientBackground from "./AmbientBackground"; +import FloatingButtons from "./FloatingButtons"; +import type { PageLayoutProps } from "@/types"; export default function PageLayout({ children, showFooter = true, - showBackToHome = true, + showBackToHome = false, title = null, - className = '', - useAmbientBackground = true, + className = "", + useAmbientBackground = false, }: PageLayoutProps) { return ( <> @@ -33,15 +32,16 @@ export default function PageLayout({ {title && (

{title} @@ -51,15 +51,15 @@ export default function PageLayout({ )} {/* Main Content */} -
{children}
+
{children}
{/* Back to Home Section */} {showBackToHome && ( -
+
-
- {loc('BackToHome')} +
+ {loc("BackToHome")}
@@ -68,65 +68,68 @@ export default function PageLayout({ {/* Footer */} {showFooter && ( -