Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds the frontend surface area for OpenList’s new Media Library (video/music/image/book browsing + admin management) and Virtual Host management, and introduces a new RootLayout + GlobalSidebar navigation shell that wraps the main app routes.
Changes:
- Add media API wrappers + TS types, plus four new media browsing pages and shared browser/layout components.
- Add admin UI for Virtual Hosts (list + add/edit) and wire it into the manage side menu + routes + i18n.
- Refactor the main app layout by introducing
RootLayoutand a global sidebar/topbar, and adjusting home toolbar/body usage accordingly.
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/media_api.ts | Adds media-related public/admin API wrappers used by new media UI. |
| src/types/virtual_host.ts | Adds VirtualHost type for manage pages. |
| src/types/setting.ts | Adds Group.MEDIA for media-related settings grouping. |
| src/types/media.ts | Adds media domain types + helper utilities. |
| src/types/index.ts | Re-exports new media + vhost types. |
| src/pages/media/video/VideoLibrary.tsx | Implements video library browsing + detail + inline player. |
| src/pages/media/music/MusicLibrary.tsx | Implements music library browsing + album detail + global audio player + lyrics UI. |
| src/pages/media/image/ImageLibrary.tsx | Implements image browsing + fullscreen viewer. |
| src/pages/media/book/BookLibrary.tsx | Implements book browsing + PDF/EPUB reader UI. |
| src/pages/media/MediaSettings.tsx | Adds media settings page (thumbnail/cover storage behavior). |
| src/pages/media/MediaLayout.tsx | Shared layout wrapper for media pages (padding for music player). |
| src/pages/media/MediaBrowser.tsx | Shared media browser (waterfall/list, search/sort, folder browsing). |
| src/pages/manage/virtual_hosts/VirtualHosts.tsx | Virtual host list UI + delete operation. |
| src/pages/manage/virtual_hosts/AddOrEdit.tsx | Virtual host create/edit form. |
| src/pages/manage/sidemenu_items.tsx | Wires Virtual Hosts + Media Library manage section into sidebar menu. |
| src/pages/manage/routes.tsx | Adds hidden routes for virtual host add/edit pages. |
| src/pages/manage/media/MediaManage.tsx | Adds admin media management UI (config, scan/scrape, CRUD). |
| src/pages/home/toolbar/Right.tsx | Moves file actions into a topbar-friendly component; stubs old floating toolbar. |
| src/pages/home/Layout.tsx | Removes old header usage and adapts to new layout container. |
| src/pages/home/Body.tsx | Removes embedded Nav/Container and adapts body spacing to new layout. |
| src/lang/en/virtual_hosts.json | Adds i18n strings for virtual host UI. |
| src/lang/en/settings.json | Adds i18n strings for media-related settings keys. |
| src/lang/en/manage.json | Adds i18n strings for new manage menu entries. |
| src/lang/en/entry.ts | Registers virtual host i18n dictionary. |
| src/components/GlobalSidebar.tsx | Adds global sidebar navigation + theme controls + transparency mode. |
| src/app/RootLayout.tsx | Adds root layout with global sidebar and new topbar (breadcrumbs/search/actions/layout toggle). |
| src/app/App.tsx | Wraps key routes in RootLayout and adds media routes (with global music player). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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")) |
There was a problem hiding this comment.
MusicPlayer is imported from ~/pages/media/music/MusicLibrary, which likely defeats the lazy(() => import(...MusicLibrary)) route-level code-splitting (the music module becomes part of the initial bundle). Extract MusicPlayer/playerState into a separate lightweight module and import that here, keeping the full MusicLibrary page lazily loaded.
| export const playTrack = (item: MediaItem) => { | ||
| const audio = initAudio() | ||
| // 使用文件路径构建播放URL | ||
| const url = `${base_path}/d${item.file_path}` | ||
| audio.src = url | ||
| audio.play() | ||
| setPlayerState((s) => ({ ...s, playing: true })) | ||
| } |
There was a problem hiding this comment.
HTMLMediaElement.play() returns a Promise and can reject (e.g. autoplay restrictions). Calling it without handling the promise can trigger unhandled promise rejections in the console and leave playerState.playing out of sync with actual playback. Consider awaiting/catching the promise and only setting playing: true on success (or reverting on failure).
| @@ -14,146 +14,104 @@ import { Motion } from "solid-motionone" | |||
| import { isTocVisible, setTocDisabled } from "~/components" | |||
| import { BiSolidBookContent } from "solid-icons/bi" | |||
|
|
|||
There was a problem hiding this comment.
Several imports are now unused (Box, createDisclosure, VStack, CgMoreO, Motion). Keeping them makes the file harder to maintain and can confuse readers about the intended behavior. Remove unused imports now that the floating toolbar has been replaced by TopBarActions.
| export interface MediaConfig { | ||
| id?: number | ||
| media_type: MediaType | ||
| enabled: boolean | ||
| scan_path: string | ||
| path_merge: boolean | ||
| last_scan_at: string | null | ||
| last_scrape_at: string | null | ||
| } |
There was a problem hiding this comment.
MediaConfig is missing fields that are used elsewhere in this PR (e.g. scraper_config in MediaSettings.tsx). With strict TS this causes property access errors and also breaks adminSaveMediaConfig({ ...imageConfig, scraper_config: ... }) due to excess-property checks. Add the missing fields to MediaConfig (and/or define a dedicated type for scraper config) so the settings and API wrappers type-check.
| if (imageConfig) { | ||
| try { | ||
| const sc = JSON.parse(imageConfig.scraper_config || "{}") | ||
| setImageStoreThumbnail(sc.store_thumbnail === "true") | ||
| setImageThumbnailMode( | ||
| (sc.thumbnail_mode as "base64" | "local") || "base64", | ||
| ) | ||
| setImageThumbnailPath(sc.thumbnail_path || "/.thumbnail") | ||
| } catch {} | ||
| } | ||
| if (bookConfig) { | ||
| try { | ||
| const sc = JSON.parse(bookConfig.scraper_config || "{}") | ||
| setBookThumbnailMode( | ||
| (sc.thumbnail_mode as "base64" | "local") || "base64", | ||
| ) | ||
| setBookThumbnailPath(sc.thumbnail_path || "/.thumbnail") | ||
| } catch {} | ||
| } | ||
| }) | ||
|
|
||
| const handleSave = async () => { | ||
| setSaving(true) | ||
| setSaveMsg("") | ||
| const configs = configData() | ||
| if (!configs) { | ||
| setSaving(false) | ||
| return | ||
| } | ||
|
|
||
| try { | ||
| // 保存图片配置 | ||
| const imageConfig = configs.find((c) => c.media_type === "image") | ||
| if (imageConfig) { | ||
| let sc: Record<string, string> = {} | ||
| try { | ||
| sc = JSON.parse(imageConfig.scraper_config || "{}") | ||
| } catch {} | ||
| sc.store_thumbnail = imageStoreThumbnail() ? "true" : "false" | ||
| sc.thumbnail_mode = imageThumbnailMode() | ||
| sc.thumbnail_path = imageThumbnailPath() | ||
| await adminSaveMediaConfig({ | ||
| ...imageConfig, | ||
| scraper_config: JSON.stringify(sc), | ||
| }) |
There was a problem hiding this comment.
This component reads/writes imageConfig.scraper_config / bookConfig.scraper_config, but MediaConfig (as added in this PR) doesn't define that property. This will fail TypeScript compilation under strict (property doesn't exist + excess-property checks when calling adminSaveMediaConfig). Update the MediaConfig type (or cast appropriately) so these accesses are valid and type-safe.
| // 与 GlobalSidebar 中的 sidebarWidth 保持一致:180px / 56px | ||
| const marginLeft = createMemo(() => { | ||
| if (isMobile()) return "0px" | ||
| return sidebarCollapsed() ? "48px" : "120px" | ||
| }) |
There was a problem hiding this comment.
margin-left is inconsistent with the actual sidebar width. GlobalSidebar uses 48px (collapsed) / 130px (expanded), but here the expanded margin is 120px, which will cause the content to overlap the sidebar by ~10px. Consider deriving this from a shared constant or exporting the computed width from GlobalSidebar to avoid drift.
| onMount(async () => { | ||
| // 动态加载 pdf.js CDN | ||
| const pdfjsLib = (window as any).pdfjsLib | ||
| if (!pdfjsLib) { | ||
| const script = document.createElement("script") | ||
| script.src = | ||
| "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js" | ||
| script.onload = () => initPDF() | ||
| document.head.appendChild(script) | ||
| } else { | ||
| initPDF() | ||
| } | ||
| }) | ||
|
|
||
| const initPDF = async () => { | ||
| try { | ||
| const pdfjsLib = (window as any).pdfjsLib | ||
| pdfjsLib.GlobalWorkerOptions.workerSrc = | ||
| "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js" | ||
| pdfDoc = await pdfjsLib.getDocument(props.url).promise |
There was a problem hiding this comment.
This introduces runtime loading of third-party scripts from cdnjs.cloudflare.com (pdf.js / epub.js). This is a security and operational risk (supply-chain compromise, CSP breakage, offline/self-hosted deployments, version drift). Prefer bundling these libs as dependencies or serving them from your own origin with integrity checks (SRI) and explicit CSP allowances.
| interface MediaLayoutProps { | ||
| children: JSX.Element | ||
| title: string | ||
| headerRight?: JSX.Element | ||
| } | ||
|
|
||
| export const MediaLayout = (props: MediaLayoutProps) => { | ||
| const { colorMode } = useColorMode() | ||
| const isDark = createMemo(() => colorMode() === "dark") | ||
|
|
||
| // 跟随主题的颜色 token | ||
| const bg = createMemo(() => | ||
| isDark() ? "rgba(15,20,35,1)" : "rgba(248,250,252,1)", | ||
| ) | ||
| const titleColor = createMemo(() => (isDark() ? "#f1f5f9" : "#0f172a")) | ||
|
|
There was a problem hiding this comment.
MediaLayout declares title/headerRight props (and even computes titleColor) but never renders them. This makes call sites misleading (they pass a title that is ignored) and leaves dead code. Either render a header using these props or remove them from the interface to match actual behavior.
| import { JSX } from "solid-js" | ||
| import { useColorMode } from "@hope-ui/solid" | ||
| import { createMemo } from "solid-js" | ||
| import { playerState } from "./music/MusicLibrary" | ||
|
|
There was a problem hiding this comment.
MediaLayout imports playerState from the huge MusicLibrary module just to compute padding. This creates a tight coupling and can force the entire music page code into any media page bundle (hurting code-splitting and initial load). Consider moving playerState (and MusicPlayer) into a small shared store/module that both MediaLayout and MusicLibrary import.
| // 数据变化时通知父组件 | ||
| createEffect(() => { | ||
| const list = items() | ||
| if (list.length > 0) props.onItemsChange?.(list) | ||
| }) |
There was a problem hiding this comment.
onItemsChange is only called when items().length > 0. When a query/page/filter change returns an empty list, parents will keep stale items from the previous fetch (e.g. the image viewer in ImageLibrary can keep an old currentPageItems). Call onItemsChange for empty lists as well so parent state stays in sync.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await adminSaveMediaConfig({ | ||
| ...imageConfig, | ||
| scraper_config: JSON.stringify(sc), | ||
| }) | ||
| } |
There was a problem hiding this comment.
handleSave 里调用 adminSaveMediaConfig 后没有检查返回的 resp.code,即使后端返回业务错误(HTTP 200 但 code != 200)也会显示“保存成功”。建议捕获并判断响应码/消息(或复用现有 handleResp* 工具)后再设置成功提示。
| {item.authors | ||
| ? JSON.parse(item.authors || "[]") | ||
| .slice(0, 2) | ||
| .join(", ") | ||
| : "-"} | ||
| </div> |
There was a problem hiding this comment.
此处直接 JSON.parse(item.authors) 没有 try/catch;只要后端返回的 authors 不是合法 JSON(或是普通字符串)就会导致整个管理表格渲染崩溃。建议复用已存在的 parseAuthors 容错解析(或至少包一层 try/catch 并回退到 -)。
| const sc = JSON.parse(imageConfig.scraper_config || "{}") | ||
| setImageStoreThumbnail(sc.store_thumbnail === "true") | ||
| setImageThumbnailMode( | ||
| (sc.thumbnail_mode as "base64" | "local") || "base64", | ||
| ) | ||
| setImageThumbnailPath(sc.thumbnail_path || "/.thumbnail") |
There was a problem hiding this comment.
MediaConfig 类型中未定义 scraper_config,但这里多处读写 imageConfig.scraper_config / bookConfig.scraper_config,在 TS strict 下会直接编译报错。建议在 src/types/media.ts 的 MediaConfig 中补充 scraper_config?: string(或按后端实际字段命名/类型调整),并同步更新相关 API 类型。
| 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) | ||
| } |
There was a problem hiding this comment.
这里使用 setInterval 轮询扫描进度,但没有在组件卸载时清理定时器;如果用户切换路由/页面,会留下后台轮询导致内存泄漏和无效请求。建议把 timer 提升到外层并在 onCleanup 中 clearInterval,同时在轮询失败/超时场景也清理。
| const handleNav = (path: string) => { | ||
| window.location.href = joinBase(path) | ||
| if (isMobile()) setSidebarVisible(false) | ||
| } |
There was a problem hiding this comment.
侧边栏跳转使用 window.location.href 会触发整页刷新,导致 SPA 状态丢失(例如全局音乐播放器播放会被中断、已加载资源需要重新请求)。建议改用路由导航(如 useNavigate / useRouter().to)在应用内跳转,并仅在需要强制刷新时才使用 location.href。
| 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: "图书文档", | ||
| }, | ||
| ] |
There was a problem hiding this comment.
GlobalSidebar 的 navItems 中 label/desc 都是硬编码中文字符串,会导致切换到英文等语言时侧边栏仍显示中文。仓库内类似导航/标题通常走 i18n(例如 src/pages/home/Nav.tsx:24-71 使用 useT() + manage.sidemenu.*)。建议把这些文案改为翻译 key,并补充 src/lang/*/*.json 对应条目。
Description / 描述
本次 PR 为 OpenList 引入了主要功能:媒体库(Media Library),同时对前端整体布局进行了重构,新增了全局侧边栏导航。
1. 媒体库(Media Library)
MediaItem,通过media_type字段区分影视、音乐、图片、书籍四种类型,支持刮削元数据(封面、评分、简介、演员/作者等)。MediaConfig配置模型,支持按类型配置扫描路径、路径合并模式等。internal/media/scanner.go)和多源刮削器(TMDB、Discogs、豆瓣、本地书籍、图片)。internal/media/id3.go)用于音乐文件元数据提取。/admin/media/...):配置管理、条目 CRUD、扫描/刮削触发、数据库清空。/fs/media/...):媒体列表、详情、专辑列表、专辑曲目、文件夹列表。VideoLibrary(影视)、MusicLibrary(音乐,含全局悬浮播放器)、ImageLibrary(图片)、BookLibrary(书籍)。MediaBrowser、MediaLayout、MediaSettings等通用媒体组件。MediaManage.tsx,支持扫描进度展示、条目编辑、刮削触发。src/utils/media_api.ts封装所有媒体相关 API 调用。src/types/media.ts定义媒体相关 TypeScript 类型。2. 全局侧边栏布局重构
GlobalSidebar.tsx:全局固定侧边栏,支持折叠/展开、透明模式、亮暗主题切换、移动端汉堡菜单,导航项包含文件、影视、音乐、图片、书籍。RootLayout.tsx:根布局组件,将侧边栏与顶栏(含面包屑、搜索、布局切换)统一管理,替代原有的Header组件。App.tsx:所有主要路由(文件浏览、媒体库各页面)均包裹在RootLayout中。Body.tsx/Layout.tsx:移除原有Header和Container组件,适配新布局结构。Motivation and Context / 背景
OpenList 此前仅提供文件浏览功能,缺乏对媒体内容的专项管理与展示能力。本次改动旨在:
How Has This Been Tested? / 测试
Checklist / 检查清单
我已阅读 CONTRIBUTING 文档。
go fmtor prettier.我已使用
go fmt或 prettier 格式化提交的代码。我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
我已相应更新了相关仓库(若适用)。