diff --git a/src/app/App.tsx b/src/app/App.tsx index fcc5b25eb..aa8f5c495 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,11 +19,17 @@ import { base_path, bus, handleRespWithoutAuthAndNotify, r } from "~/utils" import { MustUser, UserOrGuest } from "./MustUser" import "./index.css" import { globalStyles } from "./theme" +import { MusicPlayer } from "~/pages/media/music/MusicLibrary" +import { RootLayout } from "./RootLayout" const Home = lazy(() => import("~/pages/home/Layout")) const Manage = lazy(() => import("~/pages/manage")) const Login = lazy(() => import("~/pages/login")) const Test = lazy(() => import("~/pages/test")) +const VideoLibrary = lazy(() => import("~/pages/media/video/VideoLibrary")) +const MusicLibrary = lazy(() => import("~/pages/media/music/MusicLibrary")) +const ImageLibrary = lazy(() => import("~/pages/media/image/ImageLibrary")) +const BookLibrary = lazy(() => import("~/pages/media/book/BookLibrary")) const App: Component = () => { const t = useT() @@ -91,11 +97,58 @@ const App: Component = () => { } /> + {/* 带侧边栏的路由:媒体库各页面 */} + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> - + + + } /> @@ -103,7 +156,9 @@ const App: Component = () => { path="*" element={ - + + + } /> diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx new file mode 100644 index 000000000..5ba45cf6f --- /dev/null +++ b/src/app/RootLayout.tsx @@ -0,0 +1,248 @@ +import { + JSX, + createSignal, + onMount, + onCleanup, + createMemo, + Show, +} from "solid-js" +import { + GlobalSidebar, + sidebarCollapsed, + setSidebarCollapsed, +} from "~/components/GlobalSidebar" +import { useColorMode, Icon } from "@hope-ui/solid" +import { TbChevronLeft, TbChevronRight } from "solid-icons/tb" +import { Nav } from "~/pages/home/Nav" +import { Layout } from "~/pages/home/header/layout" +import { TopBarActions } from "~/pages/home/toolbar/Right" +import { useRouter } from "~/hooks" +import { getSetting, objStore, State } from "~/store" +import { BsSearch } from "solid-icons/bs" +import { bus } from "~/utils" + +interface RootLayoutProps { + children: JSX.Element +} + +// ─── 顶栏组件 ──────────────────────────────────────────────── +const TopBar = () => { + const { colorMode } = useColorMode() + const isDark = createMemo(() => colorMode() === "dark") + const { pathname } = useRouter() + + // 只在文件浏览路由下显示面包屑和文件操作 + const isFileBrowser = createMemo(() => !pathname().startsWith("/@media")) + const isFolder = createMemo(() => objStore.state === State.Folder) + + const bg = createMemo(() => + isDark() ? "rgba(15,20,35,0.95)" : "rgba(250,251,253,0.97)", + ) + const borderColor = createMemo(() => + isDark() ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)", + ) + const textColor = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b")) + const mutedColor = createMemo(() => (isDark() ? "#64748b" : "#94a3b8")) + + return ( +
+ {/* 面包屑导航 / 页面标题 */} +
+ + 📺 媒体库 + + } + > +
+ + {/* 右侧工具区 */} +
+ {/* 侧边栏收起/展开按钮 */} + + + {/* 搜索按钮和布局切换(仅文件浏览时显示) */} + + {/* 搜索按钮 */} + + + + + {/* 工具操作按钮 */} + + + {/* 布局切换 */} + + + + +
+
+ ) +} + +// ─── 根布局 ────────────────────────────────────────────────── +export const RootLayout = (props: RootLayoutProps) => { + const [isMobile, setIsMobile] = createSignal( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ) + + onMount(() => { + const handler = () => setIsMobile(window.innerWidth < 768) + window.addEventListener("resize", handler) + onCleanup(() => window.removeEventListener("resize", handler)) + }) + + // 与 GlobalSidebar 中的 sidebarWidth 保持一致:180px / 56px + const marginLeft = createMemo(() => { + if (isMobile()) return "0px" + return sidebarCollapsed() ? "48px" : "120px" + }) + + return ( +
+ + {/* 右侧内容区:自动填充剩余空间 */} +
+ {/* 顶栏 */} + + {/* 页面内容 */} +
+ {props.children} +
+
+
+ ) +} diff --git a/src/components/GlobalSidebar.tsx b/src/components/GlobalSidebar.tsx new file mode 100644 index 000000000..b39df53fc --- /dev/null +++ b/src/components/GlobalSidebar.tsx @@ -0,0 +1,575 @@ +import { + createSignal, + Show, + createMemo, + onMount, + onCleanup, + For, +} from "solid-js" +import { useLocation } from "@solidjs/router" +import { useColorMode, useColorModeValue, Icon, Image } from "@hope-ui/solid" +import { IconTypes } from "solid-icons" +import { + TbFolder, + TbMusic, + TbChevronLeft, + TbChevronRight, + TbMenu2, + TbLayersIntersect, + TbSettings, + TbAdjustments, +} from "solid-icons/tb" +import { BsPlayCircleFill, BsCardImage } from "solid-icons/bs" +import { BiSolidBookContent } from "solid-icons/bi" +import { FiSun, FiMoon } from "solid-icons/fi" +import { joinBase } from "~/utils" +import { getSetting } from "~/store" + +// ─── 导航项定义 ─────────────────────────────────────────────── +interface NavItem { + label: string + path: string + icon: IconTypes + desc: string +} + +const navItems: NavItem[] = [ + { icon: TbFolder, label: "文件", path: "/", desc: "文件管理" }, + { + icon: BsPlayCircleFill, + label: "影视", + path: "/@media/video", + desc: "电影剧集", + }, + { icon: TbMusic, label: "音乐", path: "/@media/music", desc: "专辑歌曲" }, + { icon: BsCardImage, label: "图片", path: "/@media/image", desc: "相册图库" }, + { + icon: BiSolidBookContent, + label: "书籍", + path: "/@media/books", + desc: "图书文档", + }, +] + +// ─── 全局状态(供 RootLayout 读取宽度) ────────────────────── +export const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false) +export const [sidebarVisible, setSidebarVisible] = createSignal(false) + +// ─── 透明模式持久化 ─────────────────────────────────────────── +const TRANSPARENT_KEY = "sidebar_transparent" +const initTransparent = () => { + try { + return localStorage.getItem(TRANSPARENT_KEY) === "true" + } catch { + return false + } +} +export const [sidebarTransparent, setSidebarTransparent] = + createSignal(initTransparent()) + +// ─── 主组件 ────────────────────────────────────────────────── +export const GlobalSidebar = () => { + const location = useLocation() + const { colorMode, toggleColorMode } = useColorMode() + + // 是否暗色模式 + const isDark = createMemo(() => colorMode() === "dark") + + // 移动端检测 + const [isMobile, setIsMobile] = createSignal( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ) + onMount(() => { + const handler = () => setIsMobile(window.innerWidth < 768) + window.addEventListener("resize", handler) + onCleanup(() => window.removeEventListener("resize", handler)) + }) + + const isVisible = createMemo(() => !isMobile() || sidebarVisible()) + const sidebarWidth = createMemo(() => (sidebarCollapsed() ? "48px" : "130px")) + + // Logo:从设置读取,支持亮/暗两套(与 Header.tsx 保持完全一致) + const logos = getSetting("logo").split("\n") + const logo = useColorModeValue(logos[0], logos.pop()) + // 站点标题:从数据库设置读取 + const siteTitle = getSetting("site_title") + + // ─── 主题色 token(亮/暗自适应) ───────────────────────── + const bg = createMemo(() => { + if (sidebarTransparent()) { + return isDark() ? "rgba(15,20,35,0.55)" : "rgba(255,255,255,0.55)" + } + return isDark() ? "rgba(18,22,36,0.98)" : "rgba(250,251,253,0.98)" + }) + + const borderColor = createMemo(() => + isDark() ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)", + ) + + const textPrimary = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b")) + const textSecondary = createMemo(() => (isDark() ? "#64748b" : "#94a3b8")) + const textMuted = createMemo(() => + isDark() ? "rgba(100,116,139,0.5)" : "rgba(148,163,184,0.7)", + ) + + // 激活态:使用品牌蓝而非紫色 + const activeBg = createMemo(() => + isDark() ? "rgba(59,130,246,0.18)" : "rgba(59,130,246,0.10)", + ) + const activeBorder = createMemo(() => + isDark() ? "rgba(59,130,246,0.35)" : "rgba(59,130,246,0.25)", + ) + const activeText = createMemo(() => (isDark() ? "#93c5fd" : "#2563eb")) + const activeBar = createMemo(() => "#3b82f6") + + const hoverBg = createMemo(() => + isDark() ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.04)", + ) + const hoverText = createMemo(() => (isDark() ? "#cbd5e1" : "#475569")) + + const btnBg = createMemo(() => + isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)", + ) + const btnBorder = createMemo(() => + isDark() ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)", + ) + + const shadowStyle = createMemo(() => + sidebarTransparent() + ? `4px 0 24px rgba(0,0,0,${isDark() ? "0.4" : "0.08"}), inset -1px 0 0 ${borderColor()}` + : `2px 0 16px rgba(0,0,0,${isDark() ? "0.3" : "0.06"}), inset -1px 0 0 ${borderColor()}`, + ) + + // ─── 激活判断 ───────────────────────────────────────────── + const isActive = (path: string) => { + const cur = location.pathname + if (path === "/") return !cur.startsWith("/@media") + return cur.startsWith(path) + } + + // ─── 导航跳转 ───────────────────────────────────────────── + const handleNav = (path: string) => { + window.location.href = joinBase(path) + if (isMobile()) setSidebarVisible(false) + } + + // ─── 系统设置跳转 ───────────────────────────────────────── + const handleSettings = () => { + window.location.href = joinBase("/@manage/settings/site") + if (isMobile()) setSidebarVisible(false) + } + + // ─── 透明模式切换 ───────────────────────────────────────── + const toggleTransparent = () => { + const next = !sidebarTransparent() + setSidebarTransparent(next) + try { + localStorage.setItem(TRANSPARENT_KEY, String(next)) + } catch {} + } + + // ─── 渲染 ───────────────────────────────────────────────── + return ( + <> + {/* 移动端遮罩 */} + +
setSidebarVisible(false)} + style={{ + position: "fixed", + inset: "0", + background: "rgba(0,0,0,0.5)", + "z-index": "99", + "backdrop-filter": "blur(3px)", + }} + /> + + + {/* ══════════════ 侧边栏主体 ══════════════ */} +
+ {/* ── Logo / 标题区 ── */} +
+ +
+ {/* Logo 图片(从设置读取,与 Header 保持一致) */} + + } + /> + {/* 站点标题(从数据库设置读取) */} + + + {siteTitle} + + +
+
+
+ + {/* ── 导航菜单 ── */} + + + {/* ── 底部工具栏 ── */} +
+ {/* 亮/暗模式切换 */} + + + {/* 系统设置 */} + + + {/* 透明模式切换 */} + +
+
+ + {/* ══════════════ 移动端汉堡按钮 ══════════════ */} + + + + + ) +} diff --git a/src/lang/en/manage.json b/src/lang/en/manage.json index 5829c6ee7..0f23a94c4 100644 --- a/src/lang/en/manage.json +++ b/src/lang/en/manage.json @@ -27,7 +27,13 @@ "ldap": "LDAP", "s3": "S3", "ftp": "FTP", - "traffic": "Traffic" + "traffic": "Traffic", + "media": "Media Library", + "media_video": "Video", + "media_music": "Music", + "media_image": "Images", + "media_book": "Books", + "media_settings": "Settings" }, "title": "OpenList Management", "not_admin": "You are not an admin user, please login with an admin account.", diff --git a/src/lang/en/settings.json b/src/lang/en/settings.json index c624a41d7..908e06299 100755 --- a/src/lang/en/settings.json +++ b/src/lang/en/settings.json @@ -142,5 +142,14 @@ "version": "Version", "video_autoplay": "Video autoplay", "video_types": "Video types", - "webauthn_login_enabled": "Webauthn login enabled" + "webauthn_login_enabled": "Webauthn login enabled", + "media_tmdb_key": "TMDB API Key", + "media_discogs_token": "Discogs Token", + "media_store_thumbnail": "Store thumbnail", + "media_thumbnail_mode": "Thumbnail storage mode", + "media_thumbnail_modes": { + "base64": "Base64 (Database)", + "local": "Local file" + }, + "media_thumbnail_path": "Thumbnail storage path" } diff --git a/src/pages/home/Body.tsx b/src/pages/home/Body.tsx index a8d032dcc..185af2f02 100644 --- a/src/pages/home/Body.tsx +++ b/src/pages/home/Body.tsx @@ -1,24 +1,13 @@ import { VStack } from "@hope-ui/solid" -import { Nav } from "./Nav" import { Obj } from "./Obj" import { Readme } from "./Readme" -import { Container } from "./Container" import { Sidebar } from "./Sidebar" export const Body = () => { return ( - - +
+ -
) } diff --git a/src/pages/home/Layout.tsx b/src/pages/home/Layout.tsx index d5a485f1b..69184fad5 100644 --- a/src/pages/home/Layout.tsx +++ b/src/pages/home/Layout.tsx @@ -4,7 +4,6 @@ import { getSetting } from "~/store" import { notify } from "~/utils" import { Body } from "./Body" import { Footer } from "./Footer" -import { Header } from "./header/Header" import { Toolbar } from "./toolbar/Toolbar" import { onMount } from "solid-js" @@ -21,12 +20,17 @@ const Index = () => { } }) return ( - <> -
+
- +
) } diff --git a/src/pages/home/toolbar/Right.tsx b/src/pages/home/toolbar/Right.tsx index 82ec5ac8b..2004deb0a 100644 --- a/src/pages/home/toolbar/Right.tsx +++ b/src/pages/home/toolbar/Right.tsx @@ -1,4 +1,4 @@ -import { Box, createDisclosure, VStack } from "@hope-ui/solid" +import { Box, createDisclosure, HStack, VStack } from "@hope-ui/solid" import { createMemo, Show } from "solid-js" import { RightIcon } from "./Icon" import { CgMoreO } from "solid-icons/cg" @@ -14,146 +14,104 @@ import { Motion } from "solid-motionone" import { isTocVisible, setTocDisabled } from "~/components" import { BiSolidBookContent } from "solid-icons/bi" -export const Right = () => { - const { isOpen, onToggle } = createDisclosure({ - defaultIsOpen: localStorage.getItem("more-open") === "true", - onClose: () => localStorage.setItem("more-open", "false"), - onOpen: () => localStorage.setItem("more-open", "true"), - }) - const margin = createMemo(() => (isOpen() ? "$4" : "$5")) +// ─── 顶栏工具按钮(水平排列,嵌入顶栏使用)──────────────────── +export const TopBarActions = () => { const isFolder = createMemo(() => objStore.state === State.Folder) const { refresh } = usePath() const { isShare } = useRouter() return ( - + + { + refresh(undefined, true) + }} + /> { - onToggle() - }} - /> - } + when={isFolder() && !isShare() && (userCan("write") || objStore.write)} > - - - { - refresh(undefined, true) - }} - /> - - {/* */} - { - bus.emit("tool", "new_file") - }} - /> - { - bus.emit("tool", "mkdir") - }} - /> - { - bus.emit("tool", "recursiveMove") - }} - /> - { - bus.emit("tool", "removeEmptyDirectory") - }} - /> - { - selectAll(true) - bus.emit("tool", "batchRename") - }} - /> - { - bus.emit("tool", "upload") - }} - /> - - - { - bus.emit("tool", "offline_download") - }} - /> - - - { - setTocDisabled((disabled) => !disabled) - }} - /> - - - { - bus.emit("tool", "local_settings") - }} - /> - - - + { + bus.emit("tool", "new_file") + }} + /> + { + bus.emit("tool", "mkdir") + }} + /> + { + bus.emit("tool", "recursiveMove") + }} + /> + { + bus.emit("tool", "removeEmptyDirectory") + }} + /> + { + selectAll(true) + bus.emit("tool", "batchRename") + }} + /> + { + bus.emit("tool", "upload") + }} + /> + + + { + bus.emit("tool", "offline_download") + }} + /> + + + { + setTocDisabled((disabled) => !disabled) + }} + /> - + + { + bus.emit("tool", "local_settings") + }} + /> + ) } + +// ─── 原右下角浮动按钮(已迁移到顶栏,保留空组件避免引用报错)──── +export const Right = () => { + return null +} diff --git a/src/pages/manage/media/MediaManage.tsx b/src/pages/manage/media/MediaManage.tsx new file mode 100644 index 000000000..36ddc35cb --- /dev/null +++ b/src/pages/manage/media/MediaManage.tsx @@ -0,0 +1,858 @@ +import { createSignal, createResource, Show, For, createEffect } from "solid-js" +import { + adminGetMediaConfigs, + adminSaveMediaConfig, + adminGetMediaItems, + adminUpdateMediaItem, + adminDeleteMediaItem, + adminStartMediaScan, + adminStartMediaScrape, + adminClearMediaDB, + adminGetMediaScanProgress, +} from "~/utils/media_api" +import type { MediaType, MediaItem, MediaConfig } from "~/types" + +// 别名,方便内部使用 +const getMediaConfig = async (mt: MediaType) => { + const resp = await adminGetMediaConfigs() + if (resp.code === 200) { + const found = (resp.data as MediaConfig[]).find((c) => c.media_type === mt) + return { code: 200, data: found ?? null } + } + return { code: resp.code, data: null } +} +const saveMediaConfig = adminSaveMediaConfig +const listMediaItems = adminGetMediaItems +const updateMediaItem = adminUpdateMediaItem +const deleteMediaItem = adminDeleteMediaItem +const scanMedia = adminStartMediaScan +const scrapeMedia = adminStartMediaScrape +const clearMediaDB = adminClearMediaDB +const getMediaScanProgress = adminGetMediaScanProgress + +// ==================== 通用媒体管理页 ==================== +interface MediaManagePageProps { + mediaType: MediaType + title: string + icon: string +} + +export const MediaManagePage = (props: MediaManagePageProps) => { + // 配置状态 + const [config, setConfig] = createSignal({ + media_type: props.mediaType, + enabled: false, + scan_path: "/", + path_merge: false, + last_scan_at: null, + last_scrape_at: null, + }) + const [configSaving, setConfigSaving] = createSignal(false) + + // 扫描/刮削状态 + const [scanning, setScanning] = createSignal(false) + const [scraping, setScraping] = createSignal(false) + const [progress, setProgress] = createSignal<{ + status: string + current: number + total: number + } | null>(null) + + // 数据库管理状态 + const [page, setPage] = createSignal(1) + const [editingItem, setEditingItem] = createSignal(null) + const [showEditModal, setShowEditModal] = createSignal(false) + const pageSize = 20 + + // 加载配置 + const [configData] = createResource( + () => props.mediaType, + async (mt) => { + const resp = await getMediaConfig(mt) + if (resp.code === 200 && resp.data) { + setConfig(resp.data) + } + return resp.data + }, + ) + + // 加载媒体条目 + const [itemsData, { refetch: refetchItems }] = createResource( + () => ({ media_type: props.mediaType, page: page(), page_size: pageSize }), + async (params) => { + const resp = await listMediaItems(params) + if (resp.code === 200) return resp.data + return { content: [], total: 0 } + }, + ) + + const items = () => (itemsData()?.content as MediaItem[]) ?? [] + const total = () => itemsData()?.total ?? 0 + const totalPages = () => Math.ceil(total() / pageSize) + + // 保存配置 + const handleSaveConfig = async () => { + setConfigSaving(true) + await saveMediaConfig(config()) + setConfigSaving(false) + } + + // 立即扫描 + const handleScan = async () => { + setScanning(true) + setProgress({ status: "扫描中...", current: 0, total: 0 }) + await scanMedia(props.mediaType) + // 轮询进度 + const timer = setInterval(async () => { + const resp = await getMediaScanProgress(props.mediaType) + if (resp.code === 200 && resp.data) { + const d = resp.data + setProgress({ + status: d.message || (d.running ? "扫描中..." : "完成"), + current: d.done, + total: d.total, + }) + if (!d.running) { + clearInterval(timer) + setScanning(false) + refetchItems() + } + } + }, 1000) + } + + // 立即刮削 + const handleScrape = async () => { + setScraping(true) + await scrapeMedia(props.mediaType) + setScraping(false) + refetchItems() + } + + // 清空数据库 + const handleClear = async () => { + if (!confirm(`确定要清空 ${props.title} 的所有数据吗?此操作不可恢复!`)) + return + await clearMediaDB(props.mediaType) + refetchItems() + } + + // 保存编辑 + const handleSaveItem = async () => { + if (!editingItem()) return + await updateMediaItem(editingItem()!) + setShowEditModal(false) + setEditingItem(null) + refetchItems() + } + + // 删除条目 + const handleDeleteItem = async (id: number) => { + if (!confirm("确定删除此条目?")) return + await deleteMediaItem(id) + refetchItems() + } + + return ( +
+

+ {props.icon} {props.title}管理 +

+ + {/* 配置区域 */} +
+

+ 基础配置 +

+ +
+ {/* 启用开关 */} +