Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useEffect } from "react"; | |
| import PropTypes from "prop-types"; | |
| import Link from "next/link"; | |
| import { usePathname } from "next/navigation"; | |
| import { cn } from "@/shared/utils/cn"; | |
| import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config"; | |
| import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; | |
| import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; | |
| import Button from "./Button"; | |
| import { ConfirmModal } from "./Modal"; | |
| import NineRemotePromoModal from "./NineRemotePromoModal"; | |
| // const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; | |
| const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"]; | |
| // Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web | |
| const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" }; | |
| const navItems = [ | |
| { href: "/dashboard/endpoint", label: "Endpoint & Key", icon: "api" }, | |
| { href: "/dashboard/providers", label: "Providers", icon: "dns" }, | |
| // { href: "/dashboard/basic-chat", label: "Basic Chat", icon: "chat" }, // Hidden | |
| { href: "/dashboard/combos", label: "Combos", icon: "layers" }, | |
| { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, | |
| { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" }, | |
| { href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" }, | |
| { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" }, | |
| ]; | |
| const debugItems = [ | |
| { href: "/dashboard/console-log", label: "Console Log", icon: "terminal" }, | |
| { href: "/dashboard/translator", label: "Translator", icon: "translate" }, | |
| ]; | |
| const systemItems = [ | |
| { href: "/dashboard/proxy-pools", label: "Proxy Pools", icon: "lan" }, | |
| { href: "/dashboard/skills", label: "Skills", icon: "extension" }, | |
| ]; | |
| export default function Sidebar({ onClose }) { | |
| const pathname = usePathname(); | |
| const [mediaOpen, setMediaOpen] = useState(false); | |
| const [showRemoteModal, setShowRemoteModal] = useState(false); | |
| const [isDisconnected, setIsDisconnected] = useState(false); | |
| const [updateInfo, setUpdateInfo] = useState(null); | |
| const [showUpdateModal, setShowUpdateModal] = useState(false); | |
| const [isUpdating, setIsUpdating] = useState(false); | |
| const [shutdownCountdown, setShutdownCountdown] = useState(0); | |
| const [enableTranslator, setEnableTranslator] = useState(false); | |
| const { copied, copy } = useCopyToClipboard(2000); | |
| const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest; | |
| useEffect(() => { | |
| fetch("/api/settings") | |
| .then(res => res.json()) | |
| .then(data => { if (data.enableTranslator) setEnableTranslator(true); }) | |
| .catch(() => {}); | |
| }, []); | |
| // Lazy check for new npm version on mount | |
| useEffect(() => { | |
| fetch("/api/version") | |
| .then(res => res.json()) | |
| .then(data => { if (data.hasUpdate) setUpdateInfo(data); }) | |
| .catch(() => {}); | |
| }, []); | |
| const isActive = (href) => { | |
| if (href === "/dashboard/endpoint") { | |
| return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint"); | |
| } | |
| return pathname.startsWith(href); | |
| }; | |
| // Open manual update panel (no countdown yet — user must click Copy to trigger shutdown) | |
| const handleUpdate = () => { | |
| setShowUpdateModal(false); | |
| setIsUpdating(true); | |
| }; | |
| // Triggered by Copy button inside ManualUpdatePanel: copy + countdown + shutdown | |
| const handleCopyAndShutdown = async () => { | |
| try { await navigator.clipboard.writeText(INSTALL_CMD); } catch { /* clipboard blocked */ } | |
| copy(INSTALL_CMD); | |
| let remaining = UPDATER_CONFIG.shutdownCountdownSec; | |
| setShutdownCountdown(remaining); | |
| const timer = setInterval(() => { | |
| remaining -= 1; | |
| setShutdownCountdown(remaining); | |
| if (remaining <= 0) { | |
| clearInterval(timer); | |
| fetch("/api/version/shutdown", { method: "POST" }).catch(() => {}); | |
| setIsDisconnected(true); | |
| } | |
| }, 1000); | |
| }; | |
| const handleCancelUpdate = () => { | |
| setIsUpdating(false); | |
| setShutdownCountdown(0); | |
| }; | |
| // Note: legacy updater poll removed. New flow: copy install cmd + shutdown server, | |
| // user runs the command manually in another terminal. | |
| return ( | |
| <> | |
| <aside className="flex w-72 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full"> | |
| {/* Traffic lights */} | |
| <div className="flex items-center gap-2 px-6 pt-5 pb-2"> | |
| <div className="w-3 h-3 rounded-full bg-[#FF5F56]" /> | |
| <div className="w-3 h-3 rounded-full bg-[#FFBD2E]" /> | |
| <div className="w-3 h-3 rounded-full bg-[#27C93F]" /> | |
| </div> | |
| {/* Logo */} | |
| <div className="px-6 py-4 flex flex-col gap-2"> | |
| <Link href="/dashboard" className="flex items-center gap-3"> | |
| <div className="flex items-center justify-center size-9 rounded-[10px] bg-gradient-to-br from-brand-500 to-brand-700 shadow-[var(--shadow-warm)]"> | |
| <span className="material-symbols-outlined text-white text-[20px]">hub</span> | |
| </div> | |
| <div className="flex flex-col"> | |
| <h1 className="text-lg font-semibold tracking-tight text-text-main"> | |
| {APP_CONFIG.name} | |
| </h1> | |
| <span className="text-xs text-text-muted">v{APP_CONFIG.version}</span> | |
| </div> | |
| </Link> | |
| {updateInfo && ( | |
| <div className="flex flex-col gap-1.5 rounded p-1 -m-1"> | |
| <span className="text-xs font-semibold text-green-600 dark:text-amber-500"> | |
| ↑ New version available: v{updateInfo.latestVersion} | |
| </span> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => setShowUpdateModal(true)} | |
| className="px-2 py-1 rounded bg-green-600 hover:bg-green-700 dark:bg-amber-500 dark:hover:bg-amber-600 text-white text-[11px] font-semibold transition-colors cursor-pointer" | |
| > | |
| Update now | |
| </button> | |
| <button | |
| onClick={() => copy(INSTALL_CMD)} | |
| title="Copy install command" | |
| className="flex-1 text-left hover:opacity-80 transition-opacity cursor-pointer min-w-0" | |
| > | |
| <code className="block text-[10px] text-green-600/80 dark:text-amber-400/70 font-mono truncate"> | |
| {copied ? "✓ copied!" : INSTALL_CMD} | |
| </code> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Navigation */} | |
| <nav className="flex-1 px-4 py-2 space-y-0.5 overflow-y-auto custom-scrollbar"> | |
| {navItems.map((item) => ( | |
| <Link | |
| key={item.href} | |
| href={item.href} | |
| onClick={onClose} | |
| className={cn( | |
| "flex items-center gap-3 px-3 py-1 rounded-lg transition-all group", | |
| isActive(item.href) | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span | |
| className={cn( | |
| "material-symbols-outlined text-[18px]", | |
| isActive(item.href) ? "fill-1" : "group-hover:text-primary transition-colors" | |
| )} | |
| > | |
| {item.icon} | |
| </span> | |
| <span className="text-[13px] font-medium">{item.label}</span> | |
| </Link> | |
| ))} | |
| {/* System section */} | |
| <div className="pt-3 mt-2 space-y-0.5"> | |
| <p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2"> | |
| System | |
| </p> | |
| {/* Media Providers accordion */} | |
| <button | |
| onClick={() => setMediaOpen((v) => !v)} | |
| className={cn( | |
| "w-full flex items-center gap-3 px-3 py-1 rounded-lg transition-all group", | |
| pathname.startsWith("/dashboard/media-providers") | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span className="material-symbols-outlined text-[18px]">perm_media</span> | |
| <span className="text-[13px] font-medium flex-1 text-left">Media Providers</span> | |
| <span className="material-symbols-outlined text-[14px] transition-transform" style={{ transform: mediaOpen ? "rotate(180deg)" : "rotate(0deg)" }}> | |
| expand_more | |
| </span> | |
| </button> | |
| {mediaOpen && ( | |
| <div className="pl-4"> | |
| {MEDIA_PROVIDER_KINDS.filter((k) => VISIBLE_MEDIA_KINDS.includes(k.id)).map((kind) => ( | |
| <Link | |
| key={kind.id} | |
| href={`/dashboard/media-providers/${kind.id}`} | |
| onClick={onClose} | |
| className={cn( | |
| "flex items-center gap-3 px-4 py-1 rounded-lg transition-all group", | |
| pathname.startsWith(`/dashboard/media-providers/${kind.id}`) | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span className="material-symbols-outlined text-[16px]">{kind.icon}</span> | |
| <span className="text-sm">{kind.label}</span> | |
| </Link> | |
| ))} | |
| <Link | |
| key={COMBINED_WEB_ITEM.id} | |
| href={COMBINED_WEB_ITEM.href} | |
| onClick={onClose} | |
| className={cn( | |
| "flex items-center gap-3 px-4 py-1 rounded-lg transition-all group", | |
| pathname.startsWith(COMBINED_WEB_ITEM.href) | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span className="material-symbols-outlined text-[16px]">{COMBINED_WEB_ITEM.icon}</span> | |
| <span className="text-sm">{COMBINED_WEB_ITEM.label}</span> | |
| </Link> | |
| </div> | |
| )} | |
| {systemItems.map((item) => ( | |
| <Link | |
| key={item.href} | |
| href={item.href} | |
| onClick={onClose} | |
| className={cn( | |
| "flex items-center gap-3 px-3 py-1 rounded-lg transition-all group", | |
| isActive(item.href) | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span | |
| className={cn( | |
| "material-symbols-outlined text-[18px]", | |
| isActive(item.href) ? "fill-1" : "group-hover:text-primary transition-colors" | |
| )} | |
| > | |
| {item.icon} | |
| </span> | |
| <span className="text-[13px] font-medium">{item.label}</span> | |
| </Link> | |
| ))} | |
| {/* Debug items (inside System section, before Settings) */} | |
| {debugItems.map((item) => { | |
| const show = item.href !== "/dashboard/translator" || enableTranslator; | |
| return show ? ( | |
| <Link | |
| key={item.href} | |
| href={item.href} | |
| onClick={onClose} | |
| className={cn( | |
| "flex items-center gap-3 px-3 py-1 rounded-lg transition-all group", | |
| isActive(item.href) | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span | |
| className={cn( | |
| "material-symbols-outlined text-[18px]", | |
| isActive(item.href) ? "fill-1" : "group-hover:text-primary transition-colors" | |
| )} | |
| > | |
| {item.icon} | |
| </span> | |
| <span className="text-[13px] font-medium">{item.label}</span> | |
| </Link> | |
| ) : null; | |
| })} | |
| {/* Remote */} | |
| <button | |
| onClick={() => setShowRemoteModal(true)} | |
| className={cn( | |
| "flex items-center gap-3 px-3 py-1 rounded-lg transition-all group w-full", | |
| "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span className="material-symbols-outlined text-[18px] group-hover:text-primary transition-colors"> | |
| computer | |
| </span> | |
| <span className="text-[13px] font-medium">Remote</span> | |
| </button> | |
| {/* Settings */} | |
| <Link | |
| href="/dashboard/profile" | |
| onClick={onClose} | |
| className={cn( | |
| "flex items-center gap-3 px-3 py-1 rounded-lg transition-all group", | |
| isActive("/dashboard/profile") | |
| ? "bg-primary/10 text-primary" | |
| : "text-text-muted hover:bg-surface-2 hover:text-text-main" | |
| )} | |
| > | |
| <span | |
| className={cn( | |
| "material-symbols-outlined text-[18px]", | |
| isActive("/dashboard/profile") ? "fill-1" : "group-hover:text-primary transition-colors" | |
| )} | |
| > | |
| settings | |
| </span> | |
| <span className="text-[13px] font-medium">Settings</span> | |
| </Link> | |
| </div> | |
| </nav> | |
| </aside> | |
| {/* Remote Promo Modal */} | |
| <NineRemotePromoModal isOpen={showRemoteModal} onClose={() => setShowRemoteModal(false)} /> | |
| {/* Update Confirmation Modal */} | |
| <ConfirmModal | |
| isOpen={showUpdateModal} | |
| onClose={() => setShowUpdateModal(false)} | |
| onConfirm={handleUpdate} | |
| title="Update 9Router" | |
| message={`Show install command for v${updateInfo?.latestVersion || ""}? You can copy it and shutdown to install manually.`} | |
| confirmText="Show Command" | |
| cancelText="Cancel" | |
| variant="primary" | |
| /> | |
| {/* Disconnected / Updating Overlay */} | |
| {(isDisconnected || isUpdating) && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-6"> | |
| {isUpdating ? ( | |
| <ManualUpdatePanel | |
| latestVersion={updateInfo?.latestVersion} | |
| installCmd={INSTALL_CMD} | |
| copied={copied} | |
| onCopyAndShutdown={handleCopyAndShutdown} | |
| onCancel={handleCancelUpdate} | |
| countdown={shutdownCountdown} | |
| isDisconnected={isDisconnected} | |
| /> | |
| ) : ( | |
| <div className="text-center p-8"> | |
| <div className="flex items-center justify-center size-16 rounded-full bg-red-500/20 text-red-500 mx-auto mb-4"> | |
| <span className="material-symbols-outlined text-[32px]">power_off</span> | |
| </div> | |
| <h2 className="text-xl font-semibold text-white mb-2">Server Disconnected</h2> | |
| <p className="text-text-muted mb-6">The proxy server has been stopped.</p> | |
| <Button variant="secondary" onClick={() => globalThis.location.reload()}> | |
| Reload Page | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </> | |
| ); | |
| } | |
| Sidebar.propTypes = { | |
| onClose: PropTypes.func, | |
| }; | |
| function ManualUpdatePanel({ latestVersion, installCmd, copied, onCopyAndShutdown, onCancel, countdown, isDisconnected }) { | |
| const isCountingDown = countdown > 0; | |
| return ( | |
| <div className="w-full max-w-lg rounded-xl bg-neutral-900/95 border border-white/10 p-6 text-white"> | |
| <div className="flex items-center gap-3 mb-4"> | |
| <div className="flex items-center justify-center size-11 rounded-full bg-amber-500/20 text-amber-400"> | |
| <span className="material-symbols-outlined text-[24px]">content_copy</span> | |
| </div> | |
| <div> | |
| <h2 className="text-lg font-semibold">Update 9Router{latestVersion ? ` to v${latestVersion}` : ""}</h2> | |
| <p className="text-xs text-white/60"> | |
| {isDisconnected | |
| ? "Server stopped. Paste the command into a terminal to install." | |
| : isCountingDown | |
| ? `Command copied. Server will stop in ${countdown}s...` | |
| : "Click the button below to copy the install command and shutdown."} | |
| </p> | |
| </div> | |
| </div> | |
| <p className="text-sm text-white/80 mb-2">Install command:</p> | |
| <div className="w-full px-3 py-2 rounded bg-white/5 mb-4"> | |
| <code className="text-xs font-mono text-amber-400 break-all">{installCmd}</code> | |
| </div> | |
| <ol className="text-xs text-white/70 space-y-1 list-decimal list-inside mb-4"> | |
| <li>Click <strong>Copy & Shutdown</strong> below.</li> | |
| <li>Paste the command into your terminal and press Enter.</li> | |
| <li>Run <code className="px-1 rounded bg-white/10 text-green-400">9router</code> again after install.</li> | |
| </ol> | |
| {isDisconnected ? ( | |
| <Button variant="secondary" fullWidth onClick={() => globalThis.location.reload()}> | |
| Reload Page | |
| </Button> | |
| ) : ( | |
| <div className="flex gap-2"> | |
| <Button variant="secondary" onClick={onCancel} disabled={isCountingDown}> | |
| Cancel | |
| </Button> | |
| <Button variant="primary" fullWidth onClick={onCopyAndShutdown} disabled={isCountingDown}> | |
| {copied ? "✓ Copied — shutting down..." : isCountingDown ? `Shutting down in ${countdown}s` : "Copy & Shutdown"} | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| ManualUpdatePanel.propTypes = { | |
| latestVersion: PropTypes.string, | |
| installCmd: PropTypes.string.isRequired, | |
| copied: PropTypes.bool, | |
| onCopyAndShutdown: PropTypes.func.isRequired, | |
| onCancel: PropTypes.func.isRequired, | |
| countdown: PropTypes.number, | |
| isDisconnected: PropTypes.bool, | |
| }; | |