lovart3/components/top-navbar.tsx
Your Name 024ec19c70 feat: add tutorial video button and ranking visibility toggle in navbar settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-01-31 20:37:29 +08:00

447 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Settings, ExternalLink, Briefcase, Images, Sparkles, Trophy, Building2, Crown, Medal, Award, Edit, LayoutGrid, PlayCircle, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTheme } from "next-themes";
import { useState, useEffect, useRef } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ImageAnnotator } from "@/components/image-annotator";
type RankingItem = {
rank: number;
userId: string;
userName: string;
department: string;
featuredCount: number;
};
type CustomLink = {
id: string;
name: string;
url: string;
order: number;
toolboxGroup?: number; // 1 或 2
};
type TopNavbarProps = {
siteName?: string;
siteLogo?: string;
siteLogoDark?: string;
isAdmin?: boolean;
customLinks?: CustomLink[];
toolbox1Name?: string;
toolbox1Enabled?: boolean;
toolbox2Name?: string;
toolbox2Enabled?: boolean;
tutorialVideoUrl?: string | null;
tutorialEnabled?: boolean;
rankingEnabled?: boolean;
};
export function TopNavbar({
siteName = "AI Chatbot",
siteLogo,
siteLogoDark,
isAdmin = false,
customLinks = [],
toolbox1Name = "工具箱",
toolbox1Enabled = true,
toolbox2Name = "工具箱2",
toolbox2Enabled = false,
tutorialVideoUrl,
tutorialEnabled = false,
rankingEnabled = true,
}: TopNavbarProps) {
const router = useRouter();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [ranking, setRanking] = useState<RankingItem[]>([]);
const [rankingOpen, setRankingOpen] = useState(false);
const [showTutorialModal, setShowTutorialModal] = useState(false);
const [showImageEditor, setShowImageEditor] = useState(false);
const [editorImageUrl, setEditorImageUrl] = useState<string>("");
// 移除 fileInputRef,不再需要外部上传
useEffect(() => {
setMounted(true);
}, []);
// 获取精选榜单数据
const fetchRanking = async () => {
try {
const res = await fetch("/api/featured/ranking");
const data = await res.json();
if (data.ranking) {
setRanking(data.ranking);
}
} catch (error) {
console.error("Failed to fetch ranking:", error);
}
};
// 当下拉菜单打开时获取数据
useEffect(() => {
if (rankingOpen) {
fetchRanking();
}
}, [rankingOpen]);
// 获取排名图标
const getRankIcon = (rank: number) => {
switch (rank) {
case 1:
return <Crown className="h-4 w-4 text-yellow-500" />;
case 2:
return <Medal className="h-4 w-4 text-gray-400" />;
case 3:
return <Award className="h-4 w-4 text-amber-600" />;
default:
return <span className="w-4 h-4 text-center text-xs font-bold text-gray-500">{rank}</span>;
}
};
// 决定显示哪个 LOGO
const displayLogo = (siteLogoDark && siteLogoDark.trim() !== "") ? siteLogoDark : siteLogo;
// 打开图片编辑器 - 直接打开,不触发上传
const handleOpenImageEditor = () => {
setEditorImageUrl(""); // 确保初始为空
setShowImageEditor(true);
};
// 处理图片编辑完成
const handleImageEditorConfirm = (annotatedImageUrl: string) => {
// 将编辑后的图片存储到 sessionStorage,以便在聊天页面中使用
try {
sessionStorage.setItem("pending-edited-image", annotatedImageUrl);
} catch (error) {
// sessionStorage不可用(如隐私模式),使用URL参数作为备选方案
console.warn("sessionStorage不可用,尝试使用URL参数:", error);
// 如果sessionStorage不可用,我们可以将图片数据编码到URL中
// 但由于数据可能很大,这里只记录警告,让用户手动添加图片
// 或者可以显示提示信息
}
// 导航到新聊天页面(无论sessionStorage是否成功)
router.push("/");
setShowImageEditor(false);
setEditorImageUrl("");
};
// 取消图片编辑
const handleImageEditorCancel = () => {
setShowImageEditor(false);
setEditorImageUrl("");
};
return (
<nav className="fixed top-0 left-0 right-0 z-[100] flex h-12 items-center justify-between bg-black/90 backdrop-blur-sm px-4 text-white border-b border-white/10 shadow-sm">
{/* 左侧:LOGO + 网站名称 */}
<a
href="/"
className="flex items-center gap-2 transition-opacity hover:opacity-80"
onClick={(e) => {
e.preventDefault();
window.location.href = "/";
}}
>
{displayLogo && (
<img
src={displayLogo}
alt={siteName}
className="h-6 w-6 object-contain"
/>
)}
<span className="text-sm font-semibold">{siteName}</span>
</a>
{/* 右侧区域:自定义链接 + 系统设置 */}
<div className="flex items-center gap-2">
{/* 工具箱1 (如果启用且有链接) */}
{toolbox1Enabled && customLinks.filter(l => (l.toolboxGroup || 1) === 1).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
>
<Briefcase className="h-4 w-4" />
<span className="text-sm font-medium">{toolbox1Name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[320px] p-2">
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter(l => (l.toolboxGroup || 1) === 1)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 工具箱2 (如果启用且有链接) */}
{toolbox2Enabled && customLinks.filter(l => l.toolboxGroup === 2).length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
>
<Briefcase className="h-4 w-4" />
<span className="text-sm font-medium">{toolbox2Name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[320px] p-2">
<div className="grid grid-cols-2 gap-2">
{customLinks
.filter(l => l.toolboxGroup === 2)
.sort((a, b) => a.order - b.order)
.map((link) => (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-2 rounded-md bg-muted/50 p-4 hover:bg-accent hover:text-accent-foreground text-center h-20 transition-colors"
>
<ExternalLink className="h-5 w-5 opacity-70" />
<span className="text-xs font-medium line-clamp-2">{link.name}</span>
</a>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* AI智能精选 */}
<a
href="/featured"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors text-sm font-medium"
title="AI智能精选"
onClick={(e) => {
e.preventDefault();
window.location.href = "/featured";
}}
>
<Sparkles className="h-4 w-4 text-yellow-400" />
<span>AI精选</span>
</a>
{/* 功能教程 */}
{tutorialEnabled && tutorialVideoUrl && (
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
onClick={() => setShowTutorialModal(true)}
type="button"
title="功能教程"
>
<PlayCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium">功能教程</span>
</Button>
)}
{/* 精选榜单 */}
{rankingEnabled && (
<DropdownMenu open={rankingOpen} onOpenChange={setRankingOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
type="button"
>
<Trophy className="h-4 w-4 text-orange-400" />
<span className="text-sm font-medium">精选榜</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] p-3">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Trophy className="h-5 w-5 text-orange-500" />
<span className="font-semibold">精选榜单 TOP5</span>
</div>
{ranking.length === 0 ? (
<div className="text-center text-muted-foreground py-4 text-sm">
暂无精选数据
</div>
) : (
<div className="space-y-2">
{ranking.map((item) => (
<div
key={item.userId}
className={`flex items-center gap-3 p-2 rounded-lg transition-colors ${
item.rank === 1 ? "bg-gradient-to-r from-yellow-50 to-orange-50 dark:from-yellow-900/20 dark:to-orange-900/20" :
item.rank === 2 ? "bg-gray-50 dark:bg-gray-800/50" :
item.rank === 3 ? "bg-amber-50 dark:bg-amber-900/20" :
"hover:bg-muted"
}`}
>
<div className="flex items-center justify-center w-6">
{getRankIcon(item.rank)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.userName}</p>
<p className="text-xs text-muted-foreground flex items-center gap-1 truncate">
<Building2 className="h-3 w-3" />
{item.department}
</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-orange-500">{item.featuredCount}</span>
<span className="text-xs text-muted-foreground ml-1">精选</span>
</div>
</div>
))}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* 图片编辑 */}
<Button
variant="ghost"
size="sm"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors"
onClick={handleOpenImageEditor}
type="button"
title="图片编辑"
>
<Edit className="h-4 w-4" />
<span className="text-sm font-medium">图片编辑</span>
</Button>
{/* 无限画布 */}
<button
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors text-sm font-medium"
onClick={() => {
const newId = crypto.randomUUID();
window.location.href = `/canvas/${newId}`;
}}
type="button"
title="无限画布"
>
<LayoutGrid className="h-4 w-4 text-purple-400" />
<span>无限画布</span>
</button>
{/* 我的图册 */}
<a
href="/gallery"
className="flex items-center gap-1 h-8 px-3 text-white hover:bg-white/15 hover:text-white rounded-full transition-colors text-sm font-medium"
title="我的图册"
onClick={(e) => {
e.preventDefault();
window.location.href = "/gallery";
}}
>
<Images className="h-4 w-4" />
<span>我的图册</span>
</a>
{/* 分隔线 (如果有链接或图册按钮,且是管理员) */}
{isAdmin && (
<div className="hidden h-4 w-px bg-white/20 mx-1 sm:block" />
)}
{/* 系统设置(仅管理员可见)*/}
{isAdmin && (
<a
href="/settings"
className="flex items-center justify-center h-8 w-8 text-white hover:bg-white/15 hover:text-white rounded-full"
title="系统设置"
onClick={(e) => {
e.preventDefault();
window.location.href = "/settings";
}}
>
<Settings className="h-4 w-4" />
</a>
)}
</div>
{/* 图片编辑器 */}
{showImageEditor && (
<ImageAnnotator
imageUrl={editorImageUrl}
onConfirm={handleImageEditorConfirm}
onCancel={handleImageEditorCancel}
/>
)}
{/* 功能教程弹窗 */}
{showTutorialModal && tutorialVideoUrl && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80"
onClick={() => setShowTutorialModal(false)}
>
<div
className="relative w-full max-w-4xl mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* 关闭按钮 */}
<button
onClick={() => setShowTutorialModal(false)}
className="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors"
title="关闭"
>
<X className="h-8 w-8" />
</button>
{/* 视频播放器 */}
<div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden shadow-2xl">
{tutorialVideoUrl.includes('youtube.com') || tutorialVideoUrl.includes('youtu.be') ? (
// YouTube 嵌入
<iframe
src={tutorialVideoUrl.replace('watch?v=', 'embed/').replace('youtu.be/', 'youtube.com/embed/')}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : tutorialVideoUrl.includes('bilibili.com') ? (
// Bilibili 嵌入
<iframe
src={tutorialVideoUrl.replace('www.bilibili.com/video/', 'player.bilibili.com/player.html?bvid=')}
className="w-full h-full"
allowFullScreen
/>
) : (
// 普通视频链接
<video
src={tutorialVideoUrl}
className="w-full h-full"
controls
autoPlay
/>
)}
</div>
</div>
</div>
)}
</nav>
);
}