From 324d8c0244a70f2a7bea9318c4cf5ab6f1aceb52 Mon Sep 17 00:00:00 2001 From: Maofeng Date: Fri, 31 Jul 2026 16:04:15 +0800 Subject: [PATCH] feat(blocks): add context-driven media library Provide a reusable image and video library with folders, filters, search, pagination, favorites, upload targets, detail preview, contextual actions, and single or multi-select picker flows. Keep persistence, upload, storage, and reference resolution behind MediaProvider's MediaAdapter; export the block and register its English and Simplified Chinese catalogs. Include adapter-bound UI coverage. --- apps/web/i18n.config.json | 1 + packages/blocks/package.json | 3 + packages/blocks/src/blocks/media/context.tsx | 35 + .../blocks/src/blocks/media/dimensions.ts | 59 ++ .../blocks/src/blocks/media/folder-dialog.tsx | 112 +++ packages/blocks/src/blocks/media/index.ts | 26 + .../src/blocks/media/locales/catalogs.ts | 4 + .../blocks/src/blocks/media/locales/en.ts | 66 ++ .../src/blocks/media/locales/zh-Hans.ts | 66 ++ .../src/blocks/media/media-context-menu.tsx | 251 ++++++ .../blocks/src/blocks/media/media-detail.tsx | 191 ++++ .../blocks/src/blocks/media/media-grid.tsx | 102 +++ .../blocks/src/blocks/media/media-library.tsx | 828 ++++++++++++++++++ .../src/blocks/media/media-picker-dialog.tsx | 101 +++ .../blocks/src/blocks/media/media.test.tsx | 158 ++++ packages/blocks/src/blocks/media/messages.ts | 166 ++++ packages/blocks/src/blocks/media/sidebar.tsx | 399 +++++++++ packages/blocks/src/blocks/media/types.ts | 94 ++ packages/blocks/src/blocks/media/utils.ts | 106 +++ 19 files changed, 2768 insertions(+) create mode 100644 packages/blocks/src/blocks/media/context.tsx create mode 100644 packages/blocks/src/blocks/media/dimensions.ts create mode 100644 packages/blocks/src/blocks/media/folder-dialog.tsx create mode 100644 packages/blocks/src/blocks/media/index.ts create mode 100644 packages/blocks/src/blocks/media/locales/catalogs.ts create mode 100644 packages/blocks/src/blocks/media/locales/en.ts create mode 100644 packages/blocks/src/blocks/media/locales/zh-Hans.ts create mode 100644 packages/blocks/src/blocks/media/media-context-menu.tsx create mode 100644 packages/blocks/src/blocks/media/media-detail.tsx create mode 100644 packages/blocks/src/blocks/media/media-grid.tsx create mode 100644 packages/blocks/src/blocks/media/media-library.tsx create mode 100644 packages/blocks/src/blocks/media/media-picker-dialog.tsx create mode 100644 packages/blocks/src/blocks/media/media.test.tsx create mode 100644 packages/blocks/src/blocks/media/messages.ts create mode 100644 packages/blocks/src/blocks/media/sidebar.tsx create mode 100644 packages/blocks/src/blocks/media/types.ts create mode 100644 packages/blocks/src/blocks/media/utils.ts diff --git a/apps/web/i18n.config.json b/apps/web/i18n.config.json index 25f20f6..157edd4 100644 --- a/apps/web/i18n.config.json +++ b/apps/web/i18n.config.json @@ -7,6 +7,7 @@ "@workspace/blocks/appearance/locales/{locale}", "@workspace/blocks/chats/locales/{locale}", "@workspace/blocks/layout/locales/{locale}", + "@workspace/blocks/media/locales/{locale}", "@workspace/blocks/navigation/locales/{locale}", "@workspace/blocks/notifications/locales/{locale}", "@workspace/lexical/locales/{locale}" diff --git a/packages/blocks/package.json b/packages/blocks/package.json index 7bfd2e4..ee55999 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -44,6 +44,9 @@ "./layout": "./src/blocks/layout/index.ts", "./layout/locales": "./src/blocks/layout/locales/catalogs.ts", "./layout/locales/*": "./src/blocks/layout/locales/*.ts", + "./media": "./src/blocks/media/index.ts", + "./media/locales": "./src/blocks/media/locales/catalogs.ts", + "./media/locales/*": "./src/blocks/media/locales/*.ts", "./navigation": "./src/blocks/navigation/index.ts", "./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts", "./navigation/locales/*": "./src/blocks/navigation/locales/*.ts", diff --git a/packages/blocks/src/blocks/media/context.tsx b/packages/blocks/src/blocks/media/context.tsx new file mode 100644 index 0000000..5674b2b --- /dev/null +++ b/packages/blocks/src/blocks/media/context.tsx @@ -0,0 +1,35 @@ +import * as React from "react" + +import type { MediaAdapter, MediaNotice } from "./types" + +interface MediaContextValue { + adapter: MediaAdapter + notify?: (notice: MediaNotice) => void +} + +const MediaContext = React.createContext(null) + +export interface MediaProviderProps { + adapter: MediaAdapter + children: React.ReactNode + notify?: (notice: MediaNotice) => void +} + +/** Supplies all persistence and transport dependencies for the media blocks. */ +export function MediaProvider({ + adapter, + children, + notify, +}: MediaProviderProps) { + const value = React.useMemo(() => ({ adapter, notify }), [adapter, notify]) + + return {children} +} + +export function useMedia(): MediaContextValue { + const value = React.useContext(MediaContext) + if (!value) { + throw new Error("Media blocks must be rendered inside a MediaProvider.") + } + return value +} diff --git a/packages/blocks/src/blocks/media/dimensions.ts b/packages/blocks/src/blocks/media/dimensions.ts new file mode 100644 index 0000000..38d9460 --- /dev/null +++ b/packages/blocks/src/blocks/media/dimensions.ts @@ -0,0 +1,59 @@ +export interface MediaDimensions { + height: number + width: number +} + +const METADATA_TIMEOUT_MS = 10_000 + +function waitForDimensions( + file: File, + element: HTMLImageElement | HTMLVideoElement, + successEvent: "load" | "loadedmetadata" +): Promise { + return new Promise((resolve) => { + const objectUrl = URL.createObjectURL(file) + let settled = false + const timeout = window.setTimeout(finish, METADATA_TIMEOUT_MS) + + function finish(dimensions?: MediaDimensions) { + if (settled) return + settled = true + window.clearTimeout(timeout) + URL.revokeObjectURL(objectUrl) + resolve(dimensions) + } + + element.addEventListener( + successEvent, + () => { + const width = + element instanceof HTMLVideoElement + ? element.videoWidth + : element.naturalWidth + const height = + element instanceof HTMLVideoElement + ? element.videoHeight + : element.naturalHeight + finish(width > 0 && height > 0 ? { height, width } : undefined) + }, + { once: true } + ) + element.addEventListener("error", () => finish(), { once: true }) + element.src = objectUrl + }) +} + +/** Reads intrinsic dimensions before an adapter uploads an image or video. */ +export function readMediaDimensions( + file: File +): Promise { + if (file.type.startsWith("image/")) { + return waitForDimensions(file, new Image(), "load") + } + if (file.type.startsWith("video/")) { + const video = document.createElement("video") + video.preload = "metadata" + return waitForDimensions(file, video, "loadedmetadata") + } + return Promise.resolve(undefined) +} diff --git a/packages/blocks/src/blocks/media/folder-dialog.tsx b/packages/blocks/src/blocks/media/folder-dialog.tsx new file mode 100644 index 0000000..837be1e --- /dev/null +++ b/packages/blocks/src/blocks/media/folder-dialog.tsx @@ -0,0 +1,112 @@ +import * as React from "react" +import { useTranslate } from "@workspace/i18n" +import { Button } from "@workspace/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { Input } from "@workspace/ui/components/input" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@workspace/ui/components/input-group" + +import { mediaMessages } from "./messages" + +export interface MediaFolderDialogProps { + description: string + initialName?: string + lockedSuffix?: string + maxLength?: number + open: boolean + pending?: boolean + title: string + onOpenChange: (open: boolean) => void + onSubmit: (name: string) => void +} + +export function MediaFolderDialog({ + description, + initialName = "", + lockedSuffix, + maxLength = 50, + open, + pending, + title, + onOpenChange, + onSubmit, +}: MediaFolderDialogProps) { + const t = useTranslate() + const [name, setName] = React.useState(initialName) + + React.useEffect(() => { + if (open) setName(initialName) + }, [initialName, open]) + + const normalizedName = name.trim() + const input = ( + setName(event.target.value)} + /> + ) + + return ( + + +
{ + event.preventDefault() + if (normalizedName) onSubmit(normalizedName) + }} + > + + {title} + {description} + + {lockedSuffix ? ( + + setName(event.target.value)} + /> + + {lockedSuffix} + + + ) : ( + input + )} + + + + +
+
+
+ ) +} diff --git a/packages/blocks/src/blocks/media/index.ts b/packages/blocks/src/blocks/media/index.ts new file mode 100644 index 0000000..861dec4 --- /dev/null +++ b/packages/blocks/src/blocks/media/index.ts @@ -0,0 +1,26 @@ +export { MediaProvider, useMedia } from "./context" +export type { MediaProviderProps } from "./context" +export { MediaLibrary } from "./media-library" +export type { MediaLibraryProps } from "./media-library" +export { MediaPickerDialog } from "./media-picker-dialog" +export type { MediaPickerDialogProps } from "./media-picker-dialog" +export { defaultMediaReference, formatMediaFileSize } from "./utils" +export { readMediaDimensions } from "./dimensions" +export type { MediaDimensions } from "./dimensions" +export type { + CreateMediaFolderInput, + MediaAdapter, + MediaAsset, + MediaAssetPage, + MediaAssetQuery, + MediaFilter, + MediaFolder, + MediaFolderSelection, + MediaId, + MediaKind, + MediaNotice, + MediaSelectionMode, + MediaStorageTarget, + MediaUploadInput, + UpdateMediaFolderInput, +} from "./types" diff --git a/packages/blocks/src/blocks/media/locales/catalogs.ts b/packages/blocks/src/blocks/media/locales/catalogs.ts new file mode 100644 index 0000000..84bddbd --- /dev/null +++ b/packages/blocks/src/blocks/media/locales/catalogs.ts @@ -0,0 +1,4 @@ +import type { mediaMessages } from "../messages" +import type { BlockMessageCatalog } from "../../../i18n/catalogs" + +export type MediaMessageCatalog = BlockMessageCatalog diff --git a/packages/blocks/src/blocks/media/locales/en.ts b/packages/blocks/src/blocks/media/locales/en.ts new file mode 100644 index 0000000..bf03a38 --- /dev/null +++ b/packages/blocks/src/blocks/media/locales/en.ts @@ -0,0 +1,66 @@ +import type { MediaMessageCatalog } from "./catalogs" + +export const locale = "en" +export const languageTag = "en-US" +export const messages = { + "blocks.media.actions.cancel": "Cancel", + "blocks.media.actions.copyInformation": "Copy information", + "blocks.media.actions.copyLink": "Copy link", + "blocks.media.actions.copyName": "Copy name", + "blocks.media.actions.delete": "Delete", + "blocks.media.actions.details": "View details", + "blocks.media.actions.favorite": "Favorite", + "blocks.media.actions.loadMore": "Load more", + "blocks.media.actions.moveToFolder": "Move to folder", + "blocks.media.actions.rename": "Rename", + "blocks.media.actions.retry": "Try again", + "blocks.media.actions.save": "Save", + "blocks.media.actions.saving": "Saving…", + "blocks.media.actions.select": "Select", + "blocks.media.actions.selectFolder": "Select folder {name}", + "blocks.media.actions.unfavorite": "Unfavorite", + "blocks.media.actions.upload": "Upload", + "blocks.media.actions.viewOriginal": "View original", + "blocks.media.copy.failed": "Copy failed. Check browser clipboard permissions.", + "blocks.media.deleteAsset.description": "This media item will no longer appear in the library.", + "blocks.media.deleteAsset.title": "Delete media", + "blocks.media.deleteFolder.description": "This folder and its child folders will be deleted. Their media items will become unclassified.", + "blocks.media.deleteFolder.title": "Delete folder", + "blocks.media.details.dimensions": "Dimensions", + "blocks.media.details.fileName": "File name", + "blocks.media.details.noDimensions": "Unknown", + "blocks.media.details.size": "Size", + "blocks.media.details.type": "Type", + "blocks.media.details.uploadedAt": "Uploaded", + "blocks.media.empty.description": "Upload images or videos to manage them here.", + "blocks.media.empty.title": "No media", + "blocks.media.emptySearch.description": "No media files match your search.", + "blocks.media.filters.all": "All media", + "blocks.media.filters.favorite": "Favorites", + "blocks.media.filters.image": "Images", + "blocks.media.filters.unclassified": "Unclassified", + "blocks.media.filters.video": "Videos", + "blocks.media.folders.classification": "Folders only organize media in this library and do not change file URLs.", + "blocks.media.folders.create": "New folder", + "blocks.media.folders.empty": "No folders yet. Create one.", + "blocks.media.folders.name": "Folder name", + "blocks.media.folders.newChild": "New child folder", + "blocks.media.folders.placeholder": "Enter a folder name", + "blocks.media.folders.rename": "Rename folder", + "blocks.media.folders.title": "Folders", + "blocks.media.load.error": "Unable to load media", + "blocks.media.loading": "Loading media…", + "blocks.media.picker.description": "Choose an uploaded image or video from the media library.", + "blocks.media.picker.selectedCount": "{count} selected", + "blocks.media.picker.title": "Select media", + "blocks.media.picker.useSelected": "Use selected media", + "blocks.media.renameMedia.description": "This changes the display name without changing the file or its URL.", + "blocks.media.renameMedia.title": "Rename media", + "blocks.media.search": "Search media files", + "blocks.media.title": "Media library", + "blocks.media.upload.chooseStorage": "Choose cloud storage", + "blocks.media.upload.cloud": "Upload to cloud", + "blocks.media.upload.error": "Unable to upload media.", + "blocks.media.upload.server": "Upload to server", + "blocks.media.upload.success": "{count} media files uploaded.", +} as const satisfies MediaMessageCatalog diff --git a/packages/blocks/src/blocks/media/locales/zh-Hans.ts b/packages/blocks/src/blocks/media/locales/zh-Hans.ts new file mode 100644 index 0000000..01e5638 --- /dev/null +++ b/packages/blocks/src/blocks/media/locales/zh-Hans.ts @@ -0,0 +1,66 @@ +import type { MediaMessageCatalog } from "./catalogs" + +export const locale = "zh-Hans" +export const languageTag = "zh-CN" +export const messages = { + "blocks.media.actions.cancel": "取消", + "blocks.media.actions.copyInformation": "复制媒体信息", + "blocks.media.actions.copyLink": "复制链接", + "blocks.media.actions.copyName": "复制名称", + "blocks.media.actions.delete": "删除", + "blocks.media.actions.details": "查看详情", + "blocks.media.actions.favorite": "收藏", + "blocks.media.actions.loadMore": "加载更多", + "blocks.media.actions.moveToFolder": "移动到目录", + "blocks.media.actions.rename": "重命名", + "blocks.media.actions.retry": "重新加载", + "blocks.media.actions.save": "保存", + "blocks.media.actions.saving": "正在保存…", + "blocks.media.actions.select": "选择", + "blocks.media.actions.selectFolder": "选择目录:{name}", + "blocks.media.actions.unfavorite": "取消收藏", + "blocks.media.actions.upload": "上传", + "blocks.media.actions.viewOriginal": "查看原文件", + "blocks.media.copy.failed": "复制失败,请检查浏览器剪贴板权限。", + "blocks.media.deleteAsset.description": "该媒体将不再显示在媒体库中。", + "blocks.media.deleteAsset.title": "删除媒体", + "blocks.media.deleteFolder.description": "该目录及其子目录将被删除,其中的媒体会变为未分类。", + "blocks.media.deleteFolder.title": "删除目录", + "blocks.media.details.dimensions": "尺寸", + "blocks.media.details.fileName": "文件名称", + "blocks.media.details.noDimensions": "未知", + "blocks.media.details.size": "大小", + "blocks.media.details.type": "类型", + "blocks.media.details.uploadedAt": "上传时间", + "blocks.media.empty.description": "上传图片或视频后,可在这里统一管理。", + "blocks.media.empty.title": "暂无媒体", + "blocks.media.emptySearch.description": "没有找到匹配的媒体文件。", + "blocks.media.filters.all": "全部媒体", + "blocks.media.filters.favorite": "收藏", + "blocks.media.filters.image": "图片", + "blocks.media.filters.unclassified": "未分类", + "blocks.media.filters.video": "视频", + "blocks.media.folders.classification": "目录仅用于媒体库内分类,不会改变文件链接。", + "blocks.media.folders.create": "新建目录", + "blocks.media.folders.empty": "暂无目录,点击新建。", + "blocks.media.folders.name": "目录名称", + "blocks.media.folders.newChild": "新建子目录", + "blocks.media.folders.placeholder": "输入目录名称", + "blocks.media.folders.rename": "重命名目录", + "blocks.media.folders.title": "目录", + "blocks.media.load.error": "无法加载媒体", + "blocks.media.loading": "正在加载媒体…", + "blocks.media.picker.description": "从媒体库中选择已上传的图片或视频。", + "blocks.media.picker.selectedCount": "已选择 {count} 项", + "blocks.media.picker.title": "选择媒体", + "blocks.media.picker.useSelected": "使用所选媒体", + "blocks.media.renameMedia.description": "仅修改展示名称,不会改变文件或已有链接。", + "blocks.media.renameMedia.title": "修改媒体名称", + "blocks.media.search": "搜索媒体文件", + "blocks.media.title": "媒体库", + "blocks.media.upload.chooseStorage": "选择云端存储", + "blocks.media.upload.cloud": "上传到云端", + "blocks.media.upload.error": "无法上传媒体文件。", + "blocks.media.upload.server": "上传到服务器", + "blocks.media.upload.success": "已上传 {count} 个媒体文件。", +} as const satisfies MediaMessageCatalog diff --git a/packages/blocks/src/blocks/media/media-context-menu.tsx b/packages/blocks/src/blocks/media/media-context-menu.tsx new file mode 100644 index 0000000..e981d74 --- /dev/null +++ b/packages/blocks/src/blocks/media/media-context-menu.tsx @@ -0,0 +1,251 @@ +import * as React from "react" +import { + ClipboardCopyIcon, + CloudUploadIcon, + CopyIcon, + FolderIcon, + FolderInputIcon, + HeartIcon, + InfoIcon, + LinkIcon, + PencilIcon, + Trash2Icon, + UploadIcon, +} from "lucide-react" +import { useTranslate } from "@workspace/i18n" +import { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@workspace/ui/components/context-menu" + +import { useMedia } from "./context" +import { mediaMessages } from "./messages" +import type { + MediaAsset, + MediaFolder, + MediaId, + MediaStorageTarget, +} from "./types" +import { formatMediaFileSize, getMediaFolderPath } from "./utils" + +export interface MediaContextMenuProps { + asset?: MediaAsset + children: React.ReactElement + folders: readonly MediaFolder[] + storageTargets: readonly MediaStorageTarget[] + onDelete: (asset: MediaAsset) => void + onDetails: (asset: MediaAsset) => void + onFavorite: (asset: MediaAsset) => void + onMove: (asset: MediaAsset, folderId: MediaId | null) => void + onRename: (asset: MediaAsset) => void + onUpload: (target: MediaStorageTarget) => void +} + +export function MediaContextMenu({ + asset, + children, + folders, + storageTargets, + onDelete, + onDetails, + onFavorite, + onMove, + onRename, + onUpload, +}: MediaContextMenuProps) { + const { adapter, notify } = useMedia() + const t = useTranslate() + const resolveUrl = React.useCallback( + (item: MediaAsset) => adapter.resolveUrl?.(item) ?? item.url, + [adapter] + ) + const localTargets = storageTargets.filter( + (target) => target.kind === "local" + ) + const cloudTargets = storageTargets.filter( + (target) => target.kind === "cloud" + ) + const runAfterClose = (action: VoidFunction) => window.setTimeout(action) + + const copy = async (text: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(text) + notify?.({ message: successMessage, tone: "success" }) + } catch { + notify?.({ message: t(mediaMessages.copyFailed), tone: "error" }) + } + } + + return ( + + + + {asset ? ( + <> + runAfterClose(() => onDetails(asset))} + > + + {t(mediaMessages.details)} + + onFavorite(asset)}> + + {asset.favorite + ? t(mediaMessages.unfavorite) + : t(mediaMessages.favoriteAction)} + + + + + {t(mediaMessages.moveToFolder)} + + + onMove(asset, null)} + > + + {t(mediaMessages.unclassified)} + + {folders.map((folder) => ( + onMove(asset, folder.id)} + > + + {getMediaFolderPath(folder, folders)} + + ))} + + + runAfterClose(() => onRename(asset))} + > + + {t(mediaMessages.rename)} + + + void copy(asset.name, t(mediaMessages.copyName))} + > + + {t(mediaMessages.copyName)} + + + void copy(resolveUrl(asset), t(mediaMessages.copyLink)) + } + > + + {t(mediaMessages.copyLink)} + + + void copy( + [ + `${t(mediaMessages.fileName)}: ${asset.name}`, + `${t(mediaMessages.type)}: ${asset.mimeType}`, + `${t(mediaMessages.size)}: ${formatMediaFileSize(asset.sizeBytes)}`, + `${t(mediaMessages.uploadedAt)}: ${new Date(asset.createdAt).toLocaleString()}`, + `${t(mediaMessages.copyLink)}: ${resolveUrl(asset)}`, + ].join("\n"), + t(mediaMessages.copyInformation) + ) + } + > + + {t(mediaMessages.copyInformation)} + + + runAfterClose(() => onDelete(asset))} + > + + {t(mediaMessages.delete)} + + + ) : ( + + )} + + + ) +} + +function UploadItems({ + cloudTargets, + localTargets, + onUpload, +}: { + cloudTargets: readonly MediaStorageTarget[] + localTargets: readonly MediaStorageTarget[] + onUpload: (target: MediaStorageTarget) => void +}) { + const t = useTranslate() + return ( + <> + {localTargets.map((target) => ( + + ))} + {cloudTargets.length > 1 ? ( + <> + {localTargets.length > 0 && } + + + {t(mediaMessages.chooseStorage)} + + {cloudTargets.map((target) => ( + + ))} + + + ) : ( + cloudTargets.map((target) => ( + + )) + )} + + ) +} + +function UploadTargetItem({ + target, + onUpload, +}: { + target: MediaStorageTarget + onUpload: (target: MediaStorageTarget) => void +}) { + const t = useTranslate() + return ( + onUpload(target)}> + {target.kind === "local" ? : } + {target.kind === "local" + ? t(mediaMessages.serverUpload) + : `${t(mediaMessages.cloudUpload)}: ${target.label}`} + + ) +} diff --git a/packages/blocks/src/blocks/media/media-detail.tsx b/packages/blocks/src/blocks/media/media-detail.tsx new file mode 100644 index 0000000..a24bf31 --- /dev/null +++ b/packages/blocks/src/blocks/media/media-detail.tsx @@ -0,0 +1,191 @@ +import * as React from "react" +import { + ExternalLinkIcon, + FilmIcon, + HeartIcon, + ImageIcon, + Trash2Icon, +} from "lucide-react" +import { useTranslate } from "@workspace/i18n" +import { Button } from "@workspace/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { Separator } from "@workspace/ui/components/separator" +import { cn } from "@workspace/ui/lib/utils" + +import { mediaMessages } from "./messages" +import type { MediaAsset } from "./types" +import { formatMediaFileSize } from "./utils" + +export interface MediaDetailDialogProps { + asset?: MediaAsset + deletePending?: boolean + favoritePending?: boolean + open: boolean + resolveUrl: (asset: MediaAsset) => string + onDelete: (asset: MediaAsset) => void + onFavorite: (asset: MediaAsset) => void + onOpenChange: (open: boolean) => void +} + +export function MediaDetailDialog({ + asset, + deletePending, + favoritePending, + open, + resolveUrl, + onDelete, + onFavorite, + onOpenChange, +}: MediaDetailDialogProps) { + const t = useTranslate() + const [zoom, setZoom] = React.useState(1) + const imageSource = asset?.kind === "image" ? resolveUrl(asset) : undefined + + React.useEffect(() => setZoom(1), [imageSource, open]) + + return ( + + + + {t(mediaMessages.details)} + + {asset?.name} + + + + {asset && ( + <> +
+ {asset.kind === "image" ? ( + {asset.name} { + event.preventDefault() + setZoom((current) => + Math.min(4, Math.max(1, current - event.deltaY / 400)) + ) + }} + onDoubleClick={() => setZoom(1)} + /> + ) : ( +
+ +
+ {asset.kind === "image" ? ( + + ) : ( + + )} + {asset.name} +
+ +
+ + + + + +
+ + + + + +
+ + +
+
+ + )} +
+
+ ) +} + +function MediaMetadata({ + label, + value, +}: { + label: string + value: React.ReactNode +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} diff --git a/packages/blocks/src/blocks/media/media-grid.tsx b/packages/blocks/src/blocks/media/media-grid.tsx new file mode 100644 index 0000000..f5d438d --- /dev/null +++ b/packages/blocks/src/blocks/media/media-grid.tsx @@ -0,0 +1,102 @@ +import { CheckIcon, FilmIcon, HeartIcon } from "lucide-react" +import { useTranslate } from "@workspace/i18n" +import { cn } from "@workspace/ui/lib/utils" + +import { mediaMessages } from "./messages" +import type { MediaAsset } from "./types" + +export interface MediaGridProps { + activeId?: MediaAsset["id"] + assets: readonly MediaAsset[] + resolveUrl: (asset: MediaAsset) => string + selectedIds: ReadonlySet + onActivate: (asset: MediaAsset) => void + onDetails: (asset: MediaAsset) => void + onToggle?: (asset: MediaAsset) => void +} + +export function MediaGrid({ + activeId, + assets, + resolveUrl, + selectedIds, + onActivate, + onDetails, + onToggle, +}: MediaGridProps) { + const t = useTranslate() + + return ( +
+ {assets.map((asset) => { + const selected = selectedIds.has(asset.id) + const active = activeId === asset.id + + return ( + + ) + })} +
+ ) +} diff --git a/packages/blocks/src/blocks/media/media-library.tsx b/packages/blocks/src/blocks/media/media-library.tsx new file mode 100644 index 0000000..87d53d3 --- /dev/null +++ b/packages/blocks/src/blocks/media/media-library.tsx @@ -0,0 +1,828 @@ +import * as React from "react" +import { + ChevronDownIcon, + CloudIcon, + CloudUploadIcon, + FolderPlusIcon, + LoaderCircleIcon, + SearchIcon, + UploadIcon, +} from "lucide-react" +import { useTranslate } from "@workspace/i18n" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@workspace/ui/components/alert-dialog" +import { Button } from "@workspace/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@workspace/ui/components/dropdown-menu" +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyMedia, + EmptyTitle, +} from "@workspace/ui/components/empty" +import { Input } from "@workspace/ui/components/input" +import { ScrollArea } from "@workspace/ui/components/scroll-area" +import { cn } from "@workspace/ui/lib/utils" + +import { useMedia } from "./context" +import { MediaContextMenu } from "./media-context-menu" +import { MediaDetailDialog } from "./media-detail" +import { MediaFolderDialog } from "./folder-dialog" +import { MediaGrid } from "./media-grid" +import { mediaMessages } from "./messages" +import { MediaFilterTabs, MediaSidebar } from "./sidebar" +import type { + MediaAsset, + MediaFilter, + MediaFolder, + MediaFolderSelection, + MediaId, + MediaKind, + MediaSelectionMode, + MediaStorageTarget, +} from "./types" +import { + defaultMediaReference, + getMediaFolderPath, + isMediaFile, + mediaKindForFile, + MEDIA_ACCEPT, + splitMediaName, +} from "./utils" + +const PAGE_SIZE = 40 +const EMPTY_ASSETS: readonly MediaAsset[] = [] +const EMPTY_FOLDERS: readonly MediaFolder[] = [] +const DEFAULT_STORAGE_TARGET: MediaStorageTarget = { + id: "local", + kind: "local", + label: "Local storage", +} + +type FolderDialogState = + | { mode: "create"; parentId: MediaId | null } + | { folder: MediaFolder; mode: "rename" } + +export interface MediaLibraryProps extends Omit< + React.ComponentProps<"section">, + "onChange" +> { + allowedKinds?: readonly MediaKind[] + footer?: React.ReactNode + selectedAssets?: readonly MediaAsset[] + selectionMode?: MediaSelectionMode + onSelectionChange?: (assets: readonly MediaAsset[]) => void +} + +export function MediaLibrary({ + allowedKinds, + className, + footer, + selectedAssets = EMPTY_ASSETS, + selectionMode = "none", + onSelectionChange, + ...props +}: MediaLibraryProps) { + const { adapter, notify } = useMedia() + const t = useTranslate() + const inputRef = React.useRef(null) + const uploadTargetRef = React.useRef(DEFAULT_STORAGE_TARGET) + const assetRequestId = React.useRef(0) + const foldersRequestId = React.useRef(0) + const targetsRequestId = React.useRef(0) + const [filter, setFilter] = React.useState( + allowedKinds?.length === 1 ? allowedKinds[0] : "all" + ) + const [folder, setFolder] = React.useState() + const [keyword, setKeyword] = React.useState("") + const deferredKeyword = React.useDeferredValue(keyword.trim()) + const [assets, setAssets] = React.useState([]) + const [assetPage, setAssetPage] = React.useState(1) + const [hasNextPage, setHasNextPage] = React.useState(false) + const [assetError, setAssetError] = React.useState() + const [isLoadingAssets, setIsLoadingAssets] = React.useState(true) + const [isLoadingMore, setIsLoadingMore] = React.useState(false) + const [folders, setFolders] = React.useState([]) + const [storageTargets, setStorageTargets] = React.useState< + readonly MediaStorageTarget[] + >([DEFAULT_STORAGE_TARGET]) + const [activeAsset, setActiveAsset] = React.useState() + const [contextAsset, setContextAsset] = React.useState() + const [detailOpen, setDetailOpen] = React.useState(false) + const [deleteTarget, setDeleteTarget] = React.useState() + const [folderDeleteTarget, setFolderDeleteTarget] = + React.useState() + const [renameTarget, setRenameTarget] = React.useState() + const [folderDialog, setFolderDialog] = React.useState() + const [pendingOperation, setPendingOperation] = React.useState() + + const allowedKindsKey = allowedKinds?.join(",") ?? "" + const selectedIds = React.useMemo( + () => new Set(selectedAssets.map((asset) => asset.id)), + [selectedAssets] + ) + const resolveUrl = React.useCallback( + (asset: MediaAsset) => adapter.resolveUrl?.(asset) ?? asset.url, + [adapter] + ) + + React.useEffect(() => { + if ( + (filter === "image" || filter === "video") && + allowedKinds && + !allowedKinds.includes(filter) + ) { + setFilter(allowedKinds.length === 1 ? allowedKinds[0] : "all") + } + }, [allowedKinds, allowedKindsKey, filter]) + + const reportError = React.useCallback( + (error: unknown, fallback: string) => { + const message = + error instanceof Error && error.message ? error.message : fallback + notify?.({ message, tone: "error" }) + }, + [notify] + ) + + const refreshFolders = React.useCallback(async () => { + const requestId = foldersRequestId.current + 1 + foldersRequestId.current = requestId + try { + const nextFolders = await adapter.listFolders() + if (foldersRequestId.current === requestId) setFolders(nextFolders) + } catch (error) { + if (foldersRequestId.current === requestId) { + reportError(error, t(mediaMessages.loadError)) + } + } + }, [adapter, reportError, t]) + + const refreshStorageTargets = React.useCallback(async () => { + if (!adapter.listStorageTargets) return + const requestId = targetsRequestId.current + 1 + targetsRequestId.current = requestId + try { + const targets = await adapter.listStorageTargets() + if (targetsRequestId.current === requestId) { + setStorageTargets(targets.length ? targets : [DEFAULT_STORAGE_TARGET]) + } + } catch (error) { + if (targetsRequestId.current === requestId) { + reportError(error, t(mediaMessages.loadError)) + } + } + }, [adapter, reportError, t]) + + const refreshAssets = React.useCallback(async () => { + const requestId = assetRequestId.current + 1 + assetRequestId.current = requestId + setIsLoadingAssets(true) + setAssetError(undefined) + try { + const page = await adapter.listAssets({ + allowedKinds, + filter, + folder, + keyword: deferredKeyword, + page: 1, + pageSize: PAGE_SIZE, + }) + if (assetRequestId.current !== requestId) return + setAssets(page.assets) + setAssetPage(1) + setHasNextPage(page.hasNextPage) + } catch (error) { + if (assetRequestId.current !== requestId) return + setAssets([]) + setAssetError( + error instanceof Error ? error : new Error(t(mediaMessages.loadError)) + ) + } finally { + if (assetRequestId.current === requestId) setIsLoadingAssets(false) + } + }, [ + adapter, + allowedKinds, + allowedKindsKey, + deferredKeyword, + filter, + folder, + t, + ]) + + React.useEffect(() => { + void refreshFolders() + void refreshStorageTargets() + }, [refreshFolders, refreshStorageTargets]) + + React.useEffect(() => { + void refreshAssets() + }, [refreshAssets]) + + const loadMore = async () => { + if (isLoadingMore || !hasNextPage) return + const nextPage = assetPage + 1 + setIsLoadingMore(true) + try { + const page = await adapter.listAssets({ + allowedKinds, + filter, + folder, + keyword: deferredKeyword, + page: nextPage, + pageSize: PAGE_SIZE, + }) + setAssets((current) => [...current, ...page.assets]) + setAssetPage(nextPage) + setHasNextPage(page.hasNextPage) + } catch (error) { + reportError(error, t(mediaMessages.loadError)) + } finally { + setIsLoadingMore(false) + } + } + + const runOperation = async ( + name: string, + operation: () => Promise, + onSuccess?: (value: Value) => void + ): Promise<{ ok: false } | { ok: true; value: Value }> => { + setPendingOperation(name) + try { + const value = await operation() + onSuccess?.(value) + return { ok: true, value } + } catch (error) { + reportError(error, t(mediaMessages.loadError)) + return { ok: false } + } finally { + setPendingOperation(undefined) + } + } + + const requestUpload = (target: MediaStorageTarget) => { + uploadTargetRef.current = target + inputRef.current?.click() + } + + const uploadFiles = async (files: readonly File[]) => { + const acceptedFiles = files.filter(isMediaFile) + if (!acceptedFiles.length) { + reportError( + new Error(t(mediaMessages.uploadError)), + t(mediaMessages.uploadError) + ) + return + } + if ( + allowedKinds && + acceptedFiles.some((file) => { + const kind = mediaKindForFile(file) + return !kind || !allowedKinds.includes(kind) + }) + ) { + reportError( + new Error(t(mediaMessages.uploadError)), + t(mediaMessages.uploadError) + ) + return + } + + const uploaded = await runOperation( + "upload", + () => + adapter.upload({ + files: acceptedFiles, + folderId: + folder !== "unclassified" && + (typeof folder === "string" || typeof folder === "number") + ? folder + : undefined, + target: uploadTargetRef.current, + }), + (nextAssets) => setActiveAsset(nextAssets[0]) + ) + if (!uploaded.ok) return + notify?.({ + message: t(mediaMessages.uploadSuccess, { + count: uploaded.value.length, + }), + tone: "success", + }) + await Promise.all([refreshAssets(), refreshFolders()]) + } + + const toggleSelection = (asset: MediaAsset) => { + if (selectionMode === "none") return + if (selectionMode === "single") { + onSelectionChange?.(selectedIds.has(asset.id) ? [] : [asset]) + return + } + onSelectionChange?.( + selectedIds.has(asset.id) + ? selectedAssets.filter((item) => item.id !== asset.id) + : [...selectedAssets, asset] + ) + } + + const updateFavorite = async (asset: MediaAsset) => { + const updated = await runOperation("favorite", () => + adapter.updateFavorite(asset.id, !asset.favorite) + ) + if (!updated.ok) return + setActiveAsset((current) => + current?.id === updated.value.id ? updated.value : current + ) + setContextAsset(updated.value) + onSelectionChange?.( + selectedAssets.map((item) => + item.id === updated.value.id ? updated.value : item + ) + ) + await refreshAssets() + } + + const moveAsset = async (asset: MediaAsset, folderId: MediaId | null) => { + const updated = await runOperation("move", () => + adapter.moveAsset(asset.id, folderId) + ) + if (!updated.ok) return + setActiveAsset((current) => + current?.id === updated.value.id ? updated.value : current + ) + setContextAsset(updated.value) + onSelectionChange?.( + selectedAssets.map((item) => + item.id === updated.value.id ? updated.value : item + ) + ) + await Promise.all([refreshAssets(), refreshFolders()]) + } + + const saveFolder = async (name: string) => { + if (!folderDialog) return + const saved = await runOperation("folder", () => + folderDialog.mode === "create" + ? adapter.createFolder({ name, parentId: folderDialog.parentId }) + : adapter.updateFolder({ + id: folderDialog.folder.id, + name, + parentId: folderDialog.folder.parentId, + }) + ) + if (!saved.ok) return + if (folderDialog.mode === "create") { + setFilter("all") + setFolder(saved.value.id) + } + setFolderDialog(undefined) + await Promise.all([refreshAssets(), refreshFolders()]) + } + + const saveRename = async (baseName: string) => { + if (!renameTarget) return + const { extension } = splitMediaName(renameTarget.name) + const updated = await runOperation("rename", () => + adapter.renameAsset(renameTarget.id, `${baseName}${extension}`) + ) + if (!updated.ok) return + setActiveAsset((current) => + current?.id === updated.value.id ? updated.value : current + ) + setContextAsset(updated.value) + setRenameTarget(undefined) + await refreshAssets() + } + + const deleteAsset = async () => { + if (!deleteTarget) return + const deleted = deleteTarget + const result = await runOperation("deleteAsset", () => + adapter.deleteAsset(deleted.id) + ) + if (!result.ok) return + if (activeAsset?.id === deleted.id) { + setActiveAsset(undefined) + setDetailOpen(false) + } + if (selectedIds.has(deleted.id)) { + onSelectionChange?.( + selectedAssets.filter((asset) => asset.id !== deleted.id) + ) + } + setDeleteTarget(undefined) + await Promise.all([refreshAssets(), refreshFolders()]) + } + + const deleteFolder = async () => { + if (!folderDeleteTarget) return + const deleted = folderDeleteTarget + const result = await runOperation("deleteFolder", () => + adapter.deleteFolder(deleted.id) + ) + if (!result.ok) return + if (folder === deleted.id) setFolder("unclassified") + setFolderDeleteTarget(undefined) + await Promise.all([refreshAssets(), refreshFolders()]) + } + + const showFilter = (nextFilter: MediaFilter) => { + setFilter(nextFilter) + setFolder(undefined) + } + const showFolder = (nextFolder: MediaFolderSelection) => { + setFolder(nextFolder) + setFilter("all") + } + const showDetails = (asset: MediaAsset) => { + setActiveAsset(asset) + setDetailOpen(true) + } + const selectContextAsset = (event: React.MouseEvent) => { + const target = + event.target instanceof Element + ? event.target.closest("[data-media-id]") + : null + const asset = assets.find( + (item) => String(item.id) === target?.dataset.mediaId + ) + setContextAsset(asset) + if (asset) setActiveAsset(asset) + } + + const currentFolder = + typeof folder === "string" || typeof folder === "number" + ? folders.find((item) => item.id === folder) + : undefined + const searchPlaceholder = currentFolder + ? `${t(mediaMessages.search)}: ${getMediaFolderPath(currentFolder, folders)}` + : t(mediaMessages.search) + const localTarget = + storageTargets.find((target) => target.kind === "local") ?? + DEFAULT_STORAGE_TARGET + const cloudTargets = storageTargets.filter( + (target) => target.kind === "cloud" + ) + const renameName = splitMediaName(renameTarget?.name ?? "") + const isUploading = pendingOperation === "upload" + + return ( + <> +
+ + setFolderDialog({ mode: "create", parentId }) + } + onRenameFolder={(item) => + setFolderDialog({ folder: item, mode: "rename" }) + } + onDeleteFolder={setFolderDeleteTarget} + className="hidden min-h-0 md:flex" + /> +
+
+ + + + {cloudTargets.length === 1 && ( + + )} + {cloudTargets.length > 1 && ( + + + } + > + {isUploading && uploadTargetRef.current.kind === "cloud" ? ( + + ) : ( + + )} + + {t(mediaMessages.cloudUpload)} + + + + + + + {t(mediaMessages.chooseStorage)} + + {cloudTargets.map((target) => ( + requestUpload(target)} + > + + {target.label} + + ))} + + + + )} + { + const files = Array.from(event.target.files ?? []) + if (files.length) void uploadFiles(files) + event.target.value = "" + }} + /> +
+ + + void updateFavorite(asset)} + onRename={setRenameTarget} + onMove={(asset, folderId) => void moveAsset(asset, folderId)} + onDelete={setDeleteTarget} + > +
+ {isLoadingAssets ? ( + } + title={t(mediaMessages.loading)} + /> + ) : assetError ? ( + void refreshAssets()} + > + {t(mediaMessages.retry)} + + } + /> + ) : assets.length ? ( + <> + + {hasNextPage && ( +
+ +
+ )} + + ) : ( + } + title={t(mediaMessages.emptyTitle)} + description={ + deferredKeyword + ? t(mediaMessages.emptySearchDescription) + : t(mediaMessages.emptyDescription) + } + /> + )} +
+
+
+
+ {footer && ( +
+ {footer} +
+ )} +
+ + void updateFavorite(asset)} + onDelete={setDeleteTarget} + /> + !open && setRenameTarget(undefined)} + onSubmit={(name) => void saveRename(name)} + /> + !open && setFolderDialog(undefined)} + onSubmit={(name) => void saveFolder(name)} + /> + !open && setDeleteTarget(undefined)} + onConfirm={() => void deleteAsset()} + /> + !open && setFolderDeleteTarget(undefined)} + onConfirm={() => void deleteFolder()} + /> + + ) +} + +function MediaEmpty({ + action, + description, + icon, + title, +}: { + action?: React.ReactNode + description?: string + icon?: React.ReactNode + title: string +}) { + return ( + + + {icon && {icon}} + {title} + {description && {description}} + {action} + + + ) +} + +function MediaConfirmDialog({ + description, + open, + pending, + title, + onConfirm, + onOpenChange, +}: { + description: string + open: boolean + pending: boolean + title: string + onConfirm: VoidFunction + onOpenChange: (open: boolean) => void +}) { + const t = useTranslate() + return ( + + + + {title} + {description} + + + + {t(mediaMessages.cancel)} + + + {t(mediaMessages.delete)} + + + + + ) +} diff --git a/packages/blocks/src/blocks/media/media-picker-dialog.tsx b/packages/blocks/src/blocks/media/media-picker-dialog.tsx new file mode 100644 index 0000000..925750b --- /dev/null +++ b/packages/blocks/src/blocks/media/media-picker-dialog.tsx @@ -0,0 +1,101 @@ +import * as React from "react" +import { useTranslate } from "@workspace/i18n" +import { Button } from "@workspace/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@workspace/ui/components/dialog" + +import { useMedia } from "./context" +import { MediaLibrary } from "./media-library" +import { mediaMessages } from "./messages" +import type { MediaAsset, MediaKind } from "./types" +import { defaultMediaReference } from "./utils" + +const EMPTY_SELECTION: readonly MediaAsset[] = [] + +export interface MediaPickerDialogProps { + allowedKinds?: readonly MediaKind[] + initialSelection?: readonly MediaAsset[] + multiple?: boolean + open: boolean + onConfirm: (assets: readonly MediaAsset[]) => void + onOpenChange: (open: boolean) => void +} + +export function MediaPickerDialog({ + allowedKinds, + initialSelection = EMPTY_SELECTION, + multiple = false, + open, + onConfirm, + onOpenChange, +}: MediaPickerDialogProps) { + const { adapter } = useMedia() + const t = useTranslate() + const [selected, setSelected] = + React.useState(initialSelection) + + React.useEffect(() => { + if (open) setSelected(initialSelection) + }, [initialSelection, open]) + + return ( + + + + {t(mediaMessages.selectMedia)} + + {t(mediaMessages.selectMediaDescription)} + + + + + {t(mediaMessages.selectedCount, { count: selected.length })} + +
+ + +
+ + } + /> +
+
+ ) +} diff --git a/packages/blocks/src/blocks/media/media.test.tsx b/packages/blocks/src/blocks/media/media.test.tsx new file mode 100644 index 0000000..f98bf01 --- /dev/null +++ b/packages/blocks/src/blocks/media/media.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react" +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { I18nProvider } from "@workspace/i18n" + +import { MediaProvider } from "./context" +import { MediaLibrary } from "./media-library" +import { MediaPickerDialog } from "./media-picker-dialog" +import type { MediaAdapter, MediaAsset } from "./types" +import { + defaultMediaReference, + getMediaFolderPath, + splitMediaName, +} from "./utils" + +const imageAsset: MediaAsset = { + createdAt: "2026-08-01T00:00:00Z", + favorite: false, + folderId: null, + height: 600, + id: "image-1", + kind: "image", + mimeType: "image/png", + name: "product.png", + sizeBytes: 1024, + url: "/media/product.png", + width: 800, +} + +function createAdapter(): MediaAdapter { + return { + createFolder: vi.fn(async ({ name, parentId }) => ({ + id: name, + name, + parentId, + })), + deleteAsset: vi.fn(async () => undefined), + deleteFolder: vi.fn(async () => undefined), + listAssets: vi.fn(async () => ({ + assets: [imageAsset], + hasNextPage: false, + })), + listFolders: vi.fn(async () => []), + listStorageTargets: vi.fn(async () => [ + { id: "local", kind: "local" as const, label: "Local" }, + ]), + moveAsset: vi.fn(async () => imageAsset), + renameAsset: vi.fn(async () => imageAsset), + updateFavorite: vi.fn(async () => imageAsset), + updateFolder: vi.fn(async ({ id, name, parentId }) => ({ + id, + name, + parentId, + })), + upload: vi.fn(async () => [imageAsset]), + } +} + +function MediaTestProvider({ + adapter, + children, +}: { + adapter: MediaAdapter + children: ReactNode +}) { + return ( + + {children} + + ) +} + +describe("media blocks", () => { + afterEach(cleanup) + + it("loads and uploads media only through the provider adapter", async () => { + const adapter = createAdapter() + const { container } = render( + + + + ) + + expect(await screen.findByRole("img", { name: "product.png" })).toBeTruthy() + const fileInput = + container.querySelector('input[type="file"]') + expect(fileInput).not.toBeNull() + + fireEvent.change(fileInput!, { + target: { + files: [new File(["image"], "new-image.png", { type: "image/png" })], + }, + }) + + await waitFor(() => expect(adapter.upload).toHaveBeenCalledOnce()) + expect(adapter.upload).toHaveBeenCalledWith( + expect.objectContaining({ + files: expect.arrayContaining([expect.any(File)]), + target: expect.objectContaining({ id: "local" }), + }) + ) + }) + + it("returns image picker selections with an intrinsic-size reference URL", async () => { + const adapter = createAdapter() + const onConfirm = vi.fn() + render( + + undefined} + onConfirm={onConfirm} + /> + + ) + + const image = await screen.findByRole("img", { name: "product.png" }) + fireEvent.click(image.closest("button")!) + await waitFor(() => + expect( + ( + screen.getByRole("button", { + name: "Use selected media", + }) as HTMLButtonElement + ).disabled + ).toBe(false) + ) + fireEvent.click(screen.getByRole("button", { name: "Use selected media" })) + + expect(onConfirm).toHaveBeenCalledWith([ + expect.objectContaining({ url: "/media/product.png?w=800&h=600" }), + ]) + }) + + it("builds stable media references and folder paths", () => { + expect(defaultMediaReference(imageAsset)).toBe( + "/media/product.png?w=800&h=600" + ) + expect(splitMediaName("archive.tar.gz")).toEqual({ + baseName: "archive.tar", + extension: ".gz", + }) + expect( + getMediaFolderPath({ id: "child", name: "Shoes", parentId: "root" }, [ + { id: "root", name: "Products", parentId: null }, + { id: "child", name: "Shoes", parentId: "root" }, + ]) + ).toBe("Products / Shoes") + }) +}) diff --git a/packages/blocks/src/blocks/media/messages.ts b/packages/blocks/src/blocks/media/messages.ts new file mode 100644 index 0000000..6cbd4f2 --- /dev/null +++ b/packages/blocks/src/blocks/media/messages.ts @@ -0,0 +1,166 @@ +import type { MessageDescriptor } from "@workspace/i18n" + +export const mediaMessages = { + allMedia: { id: "blocks.media.filters.all", message: "All media" }, + cancel: { id: "blocks.media.actions.cancel", message: "Cancel" }, + chooseStorage: { + id: "blocks.media.upload.chooseStorage", + message: "Choose cloud storage", + }, + cloudUpload: { + id: "blocks.media.upload.cloud", + message: "Upload to cloud", + }, + copyFailed: { + id: "blocks.media.copy.failed", + message: "Copy failed. Check browser clipboard permissions.", + }, + copyInformation: { + id: "blocks.media.actions.copyInformation", + message: "Copy information", + }, + copyLink: { id: "blocks.media.actions.copyLink", message: "Copy link" }, + copyName: { id: "blocks.media.actions.copyName", message: "Copy name" }, + createFolder: { + id: "blocks.media.folders.create", + message: "New folder", + }, + delete: { id: "blocks.media.actions.delete", message: "Delete" }, + deleteAssetDescription: { + id: "blocks.media.deleteAsset.description", + message: "This media item will no longer appear in the library.", + }, + deleteAssetTitle: { + id: "blocks.media.deleteAsset.title", + message: "Delete media", + }, + deleteFolderDescription: { + id: "blocks.media.deleteFolder.description", + message: + "This folder and its child folders will be deleted. Their media items will become unclassified.", + }, + deleteFolderTitle: { + id: "blocks.media.deleteFolder.title", + message: "Delete folder", + }, + details: { id: "blocks.media.actions.details", message: "View details" }, + dimensions: { id: "blocks.media.details.dimensions", message: "Dimensions" }, + emptyDescription: { + id: "blocks.media.empty.description", + message: "Upload images or videos to manage them here.", + }, + emptySearchDescription: { + id: "blocks.media.emptySearch.description", + message: "No media files match your search.", + }, + emptyTitle: { id: "blocks.media.empty.title", message: "No media" }, + favorite: { id: "blocks.media.filters.favorite", message: "Favorites" }, + favoriteAction: { id: "blocks.media.actions.favorite", message: "Favorite" }, + fileName: { id: "blocks.media.details.fileName", message: "File name" }, + folder: { id: "blocks.media.folders.title", message: "Folders" }, + folderName: { + id: "blocks.media.folders.name", + message: "Folder name", + }, + folderNamePlaceholder: { + id: "blocks.media.folders.placeholder", + message: "Enter a folder name", + }, + folderOnlyClassifies: { + id: "blocks.media.folders.classification", + message: + "Folders only organize media in this library and do not change file URLs.", + }, + image: { id: "blocks.media.filters.image", message: "Images" }, + library: { id: "blocks.media.title", message: "Media library" }, + loadError: { + id: "blocks.media.load.error", + message: "Unable to load media", + }, + loadMore: { id: "blocks.media.actions.loadMore", message: "Load more" }, + loading: { id: "blocks.media.loading", message: "Loading media…" }, + moveToFolder: { + id: "blocks.media.actions.moveToFolder", + message: "Move to folder", + }, + newChildFolder: { + id: "blocks.media.folders.newChild", + message: "New child folder", + }, + noDimensions: { id: "blocks.media.details.noDimensions", message: "Unknown" }, + noFolders: { + id: "blocks.media.folders.empty", + message: "No folders yet. Create one.", + }, + rename: { id: "blocks.media.actions.rename", message: "Rename" }, + renameFolder: { + id: "blocks.media.folders.rename", + message: "Rename folder", + }, + renameMedia: { + id: "blocks.media.renameMedia.title", + message: "Rename media", + }, + renameMediaDescription: { + id: "blocks.media.renameMedia.description", + message: + "This changes the display name without changing the file or its URL.", + }, + retry: { id: "blocks.media.actions.retry", message: "Try again" }, + save: { id: "blocks.media.actions.save", message: "Save" }, + saving: { id: "blocks.media.actions.saving", message: "Saving…" }, + search: { id: "blocks.media.search", message: "Search media files" }, + select: { id: "blocks.media.actions.select", message: "Select" }, + selectFolder: { + id: "blocks.media.actions.selectFolder", + message: "Select folder {name}", + }, + selectMedia: { + id: "blocks.media.picker.title", + message: "Select media", + }, + selectMediaDescription: { + id: "blocks.media.picker.description", + message: "Choose an uploaded image or video from the media library.", + }, + selectedCount: { + id: "blocks.media.picker.selectedCount", + message: "{count} selected", + }, + serverUpload: { + id: "blocks.media.upload.server", + message: "Upload to server", + }, + size: { id: "blocks.media.details.size", message: "Size" }, + type: { id: "blocks.media.details.type", message: "Type" }, + uploadedAt: { + id: "blocks.media.details.uploadedAt", + message: "Uploaded", + }, + unclassified: { + id: "blocks.media.filters.unclassified", + message: "Unclassified", + }, + unfavorite: { + id: "blocks.media.actions.unfavorite", + message: "Unfavorite", + }, + upload: { id: "blocks.media.actions.upload", message: "Upload" }, + uploadError: { + id: "blocks.media.upload.error", + message: "Unable to upload media.", + }, + uploadSuccess: { + id: "blocks.media.upload.success", + message: "{count} media files uploaded.", + }, + useSelected: { + id: "blocks.media.picker.useSelected", + message: "Use selected media", + }, + video: { id: "blocks.media.filters.video", message: "Videos" }, + viewOriginal: { + id: "blocks.media.actions.viewOriginal", + message: "View original", + }, +} as const satisfies Record diff --git a/packages/blocks/src/blocks/media/sidebar.tsx b/packages/blocks/src/blocks/media/sidebar.tsx new file mode 100644 index 0000000..7805a09 --- /dev/null +++ b/packages/blocks/src/blocks/media/sidebar.tsx @@ -0,0 +1,399 @@ +import * as React from "react" +import { + ChevronRightIcon, + FilmIcon, + FolderIcon, + FolderOpenIcon, + HeartIcon, + ImagesIcon, + LibraryIcon, + MoreHorizontalIcon, + PencilIcon, + PlusIcon, + Trash2Icon, + UnlinkIcon, +} from "lucide-react" +import { useTranslate } from "@workspace/i18n" +import { Button } from "@workspace/ui/components/button" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@workspace/ui/components/collapsible" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@workspace/ui/components/dropdown-menu" +import { cn } from "@workspace/ui/lib/utils" + +import { mediaMessages } from "./messages" +import type { + MediaFilter, + MediaFolder, + MediaFolderSelection, + MediaId, + MediaKind, +} from "./types" +import { + getChildMediaFolders, + getMediaFolderDepth, + getMediaFolderPath, +} from "./utils" + +const filters = [ + { icon: LibraryIcon, message: mediaMessages.allMedia, value: "all" }, + { icon: ImagesIcon, message: mediaMessages.image, value: "image" }, + { icon: FilmIcon, message: mediaMessages.video, value: "video" }, + { icon: HeartIcon, message: mediaMessages.favorite, value: "favorite" }, +] as const satisfies readonly { + icon: React.ComponentType<{ className?: string }> + message: (typeof mediaMessages)[keyof typeof mediaMessages] + value: MediaFilter +}[] + +export interface MediaSidebarProps { + allowedKinds?: readonly MediaKind[] + className?: string + filter: MediaFilter + folder: MediaFolderSelection + folders: readonly MediaFolder[] + onCreateFolder: (parentId: MediaId | null) => void + onDeleteFolder: (folder: MediaFolder) => void + onFilterChange: (filter: MediaFilter) => void + onFolderChange: (folder: MediaFolderSelection) => void + onRenameFolder: (folder: MediaFolder) => void +} + +function visibleFilters(allowedKinds?: readonly MediaKind[]) { + return filters.filter( + (filter) => + filter.value === "all" || + filter.value === "favorite" || + !allowedKinds || + allowedKinds.includes(filter.value) + ) +} + +export function MediaSidebar({ + allowedKinds, + className, + filter, + folder, + folders, + onCreateFolder, + onDeleteFolder, + onFilterChange, + onFolderChange, + onRenameFolder, +}: MediaSidebarProps) { + const t = useTranslate() + const rootFolders = getChildMediaFolders(folders, null) + + return ( + + ) +} + +export function MediaFilterTabs({ + allowedKinds, + filter, + folder, + folders, + onFilterChange, + onFolderChange, +}: Omit< + MediaSidebarProps, + "className" | "onCreateFolder" | "onDeleteFolder" | "onRenameFolder" +>) { + const t = useTranslate() + + return ( +
+ {visibleFilters(allowedKinds).map(({ icon: Icon, message, value }) => ( + + ))} + +
+ ) +} + +function SidebarButton({ + active, + icon: Icon, + label, + onClick, +}: { + active: boolean + icon: React.ComponentType<{ className?: string }> + label: string + onClick: VoidFunction +}) { + return ( + + ) +} + +function FolderItem({ + folder, + folders, + depth, + activeFolder, + onSelect, + onCreateFolder, + onRename, + onDelete, +}: { + folder: MediaFolder + folders: readonly MediaFolder[] + depth: number + activeFolder: MediaFolderSelection + onSelect: (folder: MediaFolderSelection) => void + onCreateFolder: (parentId: MediaId | null) => void + onRename: (folder: MediaFolder) => void + onDelete: (folder: MediaFolder) => void +}) { + const t = useTranslate() + const children = getChildMediaFolders(folders, folder.id) + const [open, setOpen] = React.useState(true) + const hasChildren = children.length > 0 + const active = activeFolder === folder.id + const Folder = active || (hasChildren && open) ? FolderOpenIcon : FolderIcon + + return ( +
  • + +
    + {hasChildren ? ( + + } + > + + + + ) : ( + + + + )} + + + + } + > + + + + {depth < 2 && ( + onCreateFolder(folder.id)}> + + {t(mediaMessages.newChildFolder)} + + )} + onRename(folder)}> + + {t(mediaMessages.rename)} + + onDelete(folder)} + > + + {t(mediaMessages.delete)} + + + +
    + {hasChildren && ( + +
      + {children.map((child) => ( + + ))} +
    +
    + )} +
    +
  • + ) +} diff --git a/packages/blocks/src/blocks/media/types.ts b/packages/blocks/src/blocks/media/types.ts new file mode 100644 index 0000000..deaff77 --- /dev/null +++ b/packages/blocks/src/blocks/media/types.ts @@ -0,0 +1,94 @@ +export type MediaId = number | string +export type MediaKind = "image" | "video" +export type MediaFilter = "all" | MediaKind | "favorite" +export type MediaFolderSelection = MediaId | "unclassified" | undefined +export type MediaSelectionMode = "multiple" | "none" | "single" + +export interface MediaAsset { + createdAt: Date | number | string + favorite: boolean + folderId: MediaId | null + height?: number | null + id: MediaId + kind: MediaKind + mimeType: string + name: string + sizeBytes: number + updatedAt?: Date | number | string + url: string + width?: number | null +} + +export interface MediaFolder { + assetCount?: number + createdAt?: Date | number | string + id: MediaId + name: string + parentId: MediaId | null + sortOrder?: number + updatedAt?: Date | number | string +} + +export interface MediaStorageTarget { + id: string + kind: "cloud" | "local" + label: string +} + +export interface MediaAssetQuery { + allowedKinds?: readonly MediaKind[] + filter: MediaFilter + folder: MediaFolderSelection + keyword: string + page: number + pageSize: number +} + +export interface MediaAssetPage { + assets: readonly MediaAsset[] + hasNextPage: boolean +} + +export interface CreateMediaFolderInput { + name: string + parentId: MediaId | null +} + +export interface UpdateMediaFolderInput extends CreateMediaFolderInput { + id: MediaId +} + +export interface MediaUploadInput { + files: readonly File[] + folderId?: MediaId + target: MediaStorageTarget +} + +/** + * The host application owns all persistence and transport. This adapter can + * be backed by a database API, direct-to-object-storage uploads, or mocks. + */ +export interface MediaAdapter { + createFolder(input: CreateMediaFolderInput): Promise + deleteAsset(id: MediaId): Promise + deleteFolder(id: MediaId): Promise + listAssets(query: MediaAssetQuery): Promise + listFolders(): Promise + listStorageTargets?(): Promise + moveAsset(id: MediaId, folderId: MediaId | null): Promise + renameAsset(id: MediaId, name: string): Promise + updateFavorite(id: MediaId, favorite: boolean): Promise + updateFolder(input: UpdateMediaFolderInput): Promise + upload(input: MediaUploadInput): Promise + + /** Resolves a stored media URL for rendering or opening the source file. */ + resolveUrl?(asset: MediaAsset): string + + /** Produces the URL returned by MediaPickerDialog. */ + resolveReference?(asset: MediaAsset): string +} + +export interface MediaNotice { + message: string + tone: "error" | "success" +} diff --git a/packages/blocks/src/blocks/media/utils.ts b/packages/blocks/src/blocks/media/utils.ts new file mode 100644 index 0000000..0c51087 --- /dev/null +++ b/packages/blocks/src/blocks/media/utils.ts @@ -0,0 +1,106 @@ +import type { MediaAsset, MediaFolder, MediaId } from "./types" + +export const MEDIA_ACCEPT = + "image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,video/ogg,video/quicktime" + +export function formatMediaFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +export function isMediaFile(file: File): boolean { + return file.type.startsWith("image/") || file.type.startsWith("video/") +} + +export function mediaKindForFile(file: File): MediaAsset["kind"] | undefined { + if (file.type.startsWith("image/")) return "image" + if (file.type.startsWith("video/")) return "video" + return undefined +} + +export function getChildMediaFolders( + folders: readonly MediaFolder[], + parentId: MediaId | null +): MediaFolder[] { + return folders + .filter((folder) => folder.parentId === parentId) + .sort( + (left, right) => + (left.sortOrder ?? 0) - (right.sortOrder ?? 0) || + left.name.localeCompare(right.name) + ) +} + +export function getMediaFolderPath( + folder: MediaFolder, + folders: readonly MediaFolder[] +): string { + const names = [folder.name] + const seen = new Set([folder.id]) + let current = folder + + while (current.parentId !== null) { + const parent = folders.find((item) => item.id === current.parentId) + if (!parent || seen.has(parent.id)) break + seen.add(parent.id) + names.unshift(parent.name) + current = parent + } + + return names.join(" / ") +} + +export function getMediaFolderDepth( + folder: MediaFolder, + folders: readonly MediaFolder[] +): number { + let depth = 0 + const seen = new Set([folder.id]) + let current = folder + + while (current.parentId !== null) { + const parent = folders.find((item) => item.id === current.parentId) + if (!parent || seen.has(parent.id)) break + seen.add(parent.id) + depth += 1 + current = parent + } + + return depth +} + +export function splitMediaName(name: string) { + const extensionIndex = name.lastIndexOf(".") + if (extensionIndex <= 0 || extensionIndex === name.length - 1) { + return { baseName: name, extension: "" } + } + + return { + baseName: name.slice(0, extensionIndex), + extension: name.slice(extensionIndex), + } +} + +export function defaultMediaReference(asset: MediaAsset): string { + if ( + asset.kind !== "image" || + !asset.width || + !asset.height || + asset.width <= 0 || + asset.height <= 0 + ) { + return asset.url + } + + const hashIndex = asset.url.indexOf("#") + const url = hashIndex === -1 ? asset.url : asset.url.slice(0, hashIndex) + const hash = hashIndex === -1 ? "" : asset.url.slice(hashIndex) + const separator = url.includes("?") + ? url.endsWith("?") || url.endsWith("&") + ? "" + : "&" + : "?" + + return `${url}${separator}w=${asset.width}&h=${asset.height}${hash}` +}