diff --git a/web/src/App.tsx b/web/src/App.tsx index 235a3b5..cbc8f69 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { TitleBar } from "./components/shell/TitleBar"; import { EditorSplit } from "./components/shell/EditorSplit"; import { HomeView } from "./components/home/HomeView"; import { SettingsView } from "./components/settings/SettingsView"; +import { LibraryView } from "./components/media/LibraryView"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { usePlaybackTicker } from "./hooks/usePlaybackTicker"; import { useAutosave } from "./hooks/useAutosave"; @@ -56,6 +57,7 @@ export default function App() { if (view === "home") return ; if (view === "settings") return ; + if (view === "library") return ; return (
diff --git a/web/src/components/home/HomeView.tsx b/web/src/components/home/HomeView.tsx index 6d2835b..3ef7477 100644 --- a/web/src/components/home/HomeView.tsx +++ b/web/src/components/home/HomeView.tsx @@ -8,7 +8,7 @@ */ import { useState } from "react"; -import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2 } from "lucide-react"; +import { Plus, FolderOpen, Settings as SettingsIcon, Film, Trash2, Library } from "lucide-react"; import { Icon } from "../ui/Icon"; import { useT } from "../../i18n"; import { useEditorUiStore } from "../../store/uiStore"; @@ -135,6 +135,8 @@ function Sidebar() { />
+ setView("library")} /> +
setView("settings")} /> diff --git a/web/src/components/media/LibraryView.tsx b/web/src/components/media/LibraryView.tsx new file mode 100644 index 0000000..dfce84f --- /dev/null +++ b/web/src/components/media/LibraryView.tsx @@ -0,0 +1,563 @@ +/** + * 全局素材库页(#56)。这是【跨项目】的全局收藏库,与项目内 `MediaPanel`(项目媒体) + * 是两套独立的东西:数据走全新的 `library_*` 命令(#55)+ `libraryStore`,不复用 + * mediaStore。整页是独立全屏视图(uiStore.view === "library"),Home 与编辑器都能进。 + * + * 布局:左侧分类树(全部 / 视频 / 音频 / 音效 / 图片 / 特效 + 自建分类),右侧工具条 + * (搜索 + 排序)+ 网格。收藏在所有子库视图都可见(「全部」聚合),通过派生函数 + * `selectEntries` 在前端过滤/排序,切分类不重拉。 + * + * 每个条目卡片:缩略图(asset 协议解码原文件,缺省回退类型字形)、文件名、以及 + * 「导入当前项目 / 改分类 / 取消收藏」操作。导入走 `importToProject`(库→项目 manifest + * 造新 asset,再 refreshMedia)。所有命令在非 Tauri 下安全降级。 + */ + +import { useEffect, useMemo, useState } from "react"; +import { + Home, + Search, + Star, + Film, + Music, + AudioWaveform, + Image as ImageIcon, + Sparkles, + Layers, + Tag, + Import, + Trash2, + type LucideIcon, +} from "lucide-react"; +import { Icon } from "../ui/Icon"; +import { useT } from "../../i18n"; +import { useEditorUiStore } from "../../store/uiStore"; +import { assetUrl } from "../../lib/asset"; +import type { LibraryEntry } from "../../lib/libraryApi"; +import { + useLibraryStore, + startLibrarySync, + selectEntries, + deriveCustomCategories, + sourceName, + type SortKey, +} from "../../store/libraryStore"; + +/** 内置分类 → 图标 + i18n 键。顺序即树中显示顺序。 */ +const BUILTIN_CATEGORIES: ReadonlyArray<{ id: string; icon: LucideIcon; labelKey: string }> = [ + { id: "all", icon: Star, labelKey: "library.cat.all" }, + { id: "video", icon: Film, labelKey: "library.cat.video" }, + { id: "audio", icon: Music, labelKey: "library.cat.audio" }, + { id: "sound", icon: AudioWaveform, labelKey: "library.cat.sound" }, + { id: "image", icon: ImageIcon, labelKey: "library.cat.image" }, + { id: "effect", icon: Sparkles, labelKey: "library.cat.effect" }, +]; + +const SORTS: ReadonlyArray<{ id: SortKey; labelKey: string }> = [ + { id: "recent", labelKey: "library.sort.recent" }, + { id: "oldest", labelKey: "library.sort.oldest" }, + { id: "type", labelKey: "library.sort.type" }, +]; + +/** 素材类型 → 卡片回退字形(无缩略图时)。 */ +function typeIcon(type: string): LucideIcon { + switch (type) { + case "video": + return Film; + case "audio": + return Music; + case "image": + return ImageIcon; + case "effect": + return Sparkles; + default: + return Layers; + } +} + +export function LibraryView() { + const t = useT(); + const setView = useEditorUiStore((s) => s.setView); + const entries = useLibraryStore((s) => s.entries); + const loading = useLibraryStore((s) => s.loading); + const error = useLibraryStore((s) => s.error); + const selectedCategory = useLibraryStore((s) => s.selectedCategory); + const search = useLibraryStore((s) => s.search); + const sort = useLibraryStore((s) => s.sort); + const setSearch = useLibraryStore((s) => s.setSearch); + const setSort = useLibraryStore((s) => s.setSort); + + useEffect(() => { + void startLibrarySync(); + }, []); + + const visible = useMemo( + () => selectEntries(entries, selectedCategory, search, sort), + [entries, selectedCategory, search, sort], + ); + const customCategories = useMemo(() => deriveCustomCategories(entries), [entries]); + + return ( +
+ + +
+ {/* 顶部条:返回主页 + 标题 + 搜索 + 排序 */} +
+ +

+ {t("library.title")} +

+ +
+ + + +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ ); +} + +function CategoryTree({ custom }: { custom: ReadonlyArray }) { + const t = useT(); + const selectedCategory = useLibraryStore((s) => s.selectedCategory); + const setSelectedCategory = useLibraryStore((s) => s.setSelectedCategory); + + return ( + + ); +} + +function CategoryRow({ + icon, + label, + active, + onClick, +}: { + icon: LucideIcon; + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function SearchBox({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + style={{ + width: 160, + border: "none", + outline: "none", + background: "transparent", + color: "var(--text-primary)", + fontSize: "var(--fs-sm)", + }} + /> +
+ ); +} + +function SortSelect({ value, onChange }: { value: SortKey; onChange: (s: SortKey) => void }) { + const t = useT(); + return ( + + ); +} + +function Grid({ + entries, + loading, + totalEmpty, +}: { + entries: ReadonlyArray; + loading: boolean; + totalEmpty: boolean; +}) { + const t = useT(); + + if (entries.length === 0) { + return ( +
+ {loading ? t("library.loading") : totalEmpty ? t("library.empty") : t("library.noMatch")} +
+ ); + } + + return ( +
+
+ {entries.map((e) => ( + + ))} +
+
+ ); +} + +function EntryCard({ entry }: { entry: LibraryEntry }) { + const t = useT(); + const importToProject = useLibraryStore((s) => s.importToProject); + const unfavorite = useLibraryStore((s) => s.unfavorite); + const categorize = useLibraryStore((s) => s.categorize); + const [hovered, setHovered] = useState(false); + const [busy, setBusy] = useState(false); + + const name = sourceName(entry.source) || entry.id; + // 缩略图:库条目 thumb 优先,否则按 source 让 WebView 解码原文件(asset 协议)。 + const thumb = assetUrl(entry.thumb ?? entry.source); + + const handleImport = async () => { + setBusy(true); + try { + await importToProject(entry.id); + } finally { + setBusy(false); + } + }; + + const handleCategorize = () => { + const next = window.prompt(t("library.categorizePrompt"), entry.category ?? ""); + if (next === null) return; // 取消 + const trimmed = next.trim(); + void categorize(entry.id, trimmed === "" ? null : trimmed); + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + title={name} + style={{ display: "flex", flexDirection: "column", gap: 4, position: "relative" }} + > +
+ {thumb && entry.type === "image" ? ( + {name} + ) : thumb && entry.type === "video" ? ( +
+ +
+ {name} +
+
+ ); +} + +function CardAction({ + icon, + title, + onClick, + danger, + disabled, +}: { + icon: LucideIcon; + title: string; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/web/src/components/shell/TitleBar.tsx b/web/src/components/shell/TitleBar.tsx index 5654a4d..081e735 100644 --- a/web/src/components/shell/TitleBar.tsx +++ b/web/src/components/shell/TitleBar.tsx @@ -8,7 +8,7 @@ * menu, the in-app menu entry point for an environment without a native menu bar. */ -import { Upload, Home, Settings as SettingsIcon } from "lucide-react"; +import { Upload, Home, Settings as SettingsIcon, Library } from "lucide-react"; import { Icon } from "../ui/Icon"; import { ViewMenu } from "./ViewMenu"; import { useEditorUiStore } from "../../store/uiStore"; @@ -104,7 +104,23 @@ export function TitleBar() {
- {/* Trailing: Settings + Export. */} + {/* Trailing: Library + Settings + Export. */} +