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.
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"@workspace/blocks/appearance/locales/{locale}",
|
"@workspace/blocks/appearance/locales/{locale}",
|
||||||
"@workspace/blocks/chats/locales/{locale}",
|
"@workspace/blocks/chats/locales/{locale}",
|
||||||
"@workspace/blocks/layout/locales/{locale}",
|
"@workspace/blocks/layout/locales/{locale}",
|
||||||
|
"@workspace/blocks/media/locales/{locale}",
|
||||||
"@workspace/blocks/navigation/locales/{locale}",
|
"@workspace/blocks/navigation/locales/{locale}",
|
||||||
"@workspace/blocks/notifications/locales/{locale}",
|
"@workspace/blocks/notifications/locales/{locale}",
|
||||||
"@workspace/lexical/locales/{locale}"
|
"@workspace/lexical/locales/{locale}"
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
"./layout": "./src/blocks/layout/index.ts",
|
"./layout": "./src/blocks/layout/index.ts",
|
||||||
"./layout/locales": "./src/blocks/layout/locales/catalogs.ts",
|
"./layout/locales": "./src/blocks/layout/locales/catalogs.ts",
|
||||||
"./layout/locales/*": "./src/blocks/layout/locales/*.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": "./src/blocks/navigation/index.ts",
|
||||||
"./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
|
"./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
|
||||||
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
|
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
|
||||||
|
|||||||
@@ -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<MediaContextValue | null>(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 <MediaContext.Provider value={value}>{children}</MediaContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMedia(): MediaContextValue {
|
||||||
|
const value = React.useContext(MediaContext)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error("Media blocks must be rendered inside a MediaProvider.")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -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<MediaDimensions | undefined> {
|
||||||
|
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<MediaDimensions | undefined> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
maxLength={maxLength}
|
||||||
|
placeholder={t(mediaMessages.folderNamePlaceholder)}
|
||||||
|
aria-label={t(mediaMessages.folderName)}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<form
|
||||||
|
className="contents"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (normalizedName) onSubmit(normalizedName)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{lockedSuffix ? (
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
maxLength={maxLength}
|
||||||
|
placeholder={t(mediaMessages.folderNamePlaceholder)}
|
||||||
|
aria-label={t(mediaMessages.folderName)}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupText>{lockedSuffix}</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
) : (
|
||||||
|
input
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t(mediaMessages.cancel)}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!normalizedName || pending}>
|
||||||
|
{pending ? t(mediaMessages.saving) : t(mediaMessages.save)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type { mediaMessages } from "../messages"
|
||||||
|
import type { BlockMessageCatalog } from "../../../i18n/catalogs"
|
||||||
|
|
||||||
|
export type MediaMessageCatalog = BlockMessageCatalog<typeof mediaMessages>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger render={children} />
|
||||||
|
<ContextMenuContent>
|
||||||
|
{asset ? (
|
||||||
|
<>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => runAfterClose(() => onDetails(asset))}
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
{t(mediaMessages.details)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => onFavorite(asset)}>
|
||||||
|
<HeartIcon
|
||||||
|
className={asset.favorite ? "fill-current" : undefined}
|
||||||
|
/>
|
||||||
|
{asset.favorite
|
||||||
|
? t(mediaMessages.unfavorite)
|
||||||
|
: t(mediaMessages.favoriteAction)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger>
|
||||||
|
<FolderInputIcon />
|
||||||
|
{t(mediaMessages.moveToFolder)}
|
||||||
|
</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
disabled={asset.folderId === null}
|
||||||
|
onClick={() => onMove(asset, null)}
|
||||||
|
>
|
||||||
|
<FolderIcon />
|
||||||
|
{t(mediaMessages.unclassified)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
{folders.map((folder) => (
|
||||||
|
<ContextMenuItem
|
||||||
|
key={folder.id}
|
||||||
|
disabled={asset.folderId === folder.id}
|
||||||
|
onClick={() => onMove(asset, folder.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon />
|
||||||
|
{getMediaFolderPath(folder, folders)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
))}
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => runAfterClose(() => onRename(asset))}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
{t(mediaMessages.rename)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => void copy(asset.name, t(mediaMessages.copyName))}
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
{t(mediaMessages.copyName)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
void copy(resolveUrl(asset), t(mediaMessages.copyLink))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
{t(mediaMessages.copyLink)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ClipboardCopyIcon />
|
||||||
|
{t(mediaMessages.copyInformation)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => runAfterClose(() => onDelete(asset))}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
{t(mediaMessages.delete)}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<UploadItems
|
||||||
|
cloudTargets={cloudTargets}
|
||||||
|
localTargets={localTargets}
|
||||||
|
onUpload={onUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadItems({
|
||||||
|
cloudTargets,
|
||||||
|
localTargets,
|
||||||
|
onUpload,
|
||||||
|
}: {
|
||||||
|
cloudTargets: readonly MediaStorageTarget[]
|
||||||
|
localTargets: readonly MediaStorageTarget[]
|
||||||
|
onUpload: (target: MediaStorageTarget) => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{localTargets.map((target) => (
|
||||||
|
<UploadTargetItem key={target.id} target={target} onUpload={onUpload} />
|
||||||
|
))}
|
||||||
|
{cloudTargets.length > 1 ? (
|
||||||
|
<>
|
||||||
|
{localTargets.length > 0 && <ContextMenuSeparator />}
|
||||||
|
<ContextMenuGroup>
|
||||||
|
<ContextMenuLabel>
|
||||||
|
{t(mediaMessages.chooseStorage)}
|
||||||
|
</ContextMenuLabel>
|
||||||
|
{cloudTargets.map((target) => (
|
||||||
|
<UploadTargetItem
|
||||||
|
key={target.id}
|
||||||
|
target={target}
|
||||||
|
onUpload={onUpload}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ContextMenuGroup>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
cloudTargets.map((target) => (
|
||||||
|
<UploadTargetItem
|
||||||
|
key={target.id}
|
||||||
|
target={target}
|
||||||
|
onUpload={onUpload}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadTargetItem({
|
||||||
|
target,
|
||||||
|
onUpload,
|
||||||
|
}: {
|
||||||
|
target: MediaStorageTarget
|
||||||
|
onUpload: (target: MediaStorageTarget) => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslate()
|
||||||
|
return (
|
||||||
|
<ContextMenuItem onClick={() => onUpload(target)}>
|
||||||
|
{target.kind === "local" ? <UploadIcon /> : <CloudUploadIcon />}
|
||||||
|
{target.kind === "local"
|
||||||
|
? t(mediaMessages.serverUpload)
|
||||||
|
: `${t(mediaMessages.cloudUpload)}: ${target.label}`}
|
||||||
|
</ContextMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t(mediaMessages.details)}</DialogTitle>
|
||||||
|
<DialogDescription className="truncate">
|
||||||
|
{asset?.name}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{asset && (
|
||||||
|
<>
|
||||||
|
<div className="grid max-h-[55dvh] min-h-56 place-items-center overflow-hidden rounded-lg bg-muted/50">
|
||||||
|
{asset.kind === "image" ? (
|
||||||
|
<img
|
||||||
|
src={imageSource}
|
||||||
|
alt={asset.name}
|
||||||
|
draggable={false}
|
||||||
|
className="max-h-[55dvh] max-w-full touch-none object-contain select-none"
|
||||||
|
style={{ transform: `scale(${zoom})` }}
|
||||||
|
onWheel={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setZoom((current) =>
|
||||||
|
Math.min(4, Math.max(1, current - event.deltaY / 400))
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => setZoom(1)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video
|
||||||
|
src={resolveUrl(asset)}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
className="max-h-[55dvh] max-w-full bg-black object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{asset.kind === "image" ? (
|
||||||
|
<ImageIcon className="size-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<FilmIcon className="size-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<strong className="truncate">{asset.name}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-[repeat(3,minmax(0,1fr))_auto]">
|
||||||
|
<MediaMetadata
|
||||||
|
label={t(mediaMessages.type)}
|
||||||
|
value={asset.mimeType}
|
||||||
|
/>
|
||||||
|
<MediaMetadata
|
||||||
|
label={t(mediaMessages.size)}
|
||||||
|
value={formatMediaFileSize(asset.sizeBytes)}
|
||||||
|
/>
|
||||||
|
<MediaMetadata
|
||||||
|
label={t(mediaMessages.dimensions)}
|
||||||
|
value={
|
||||||
|
asset.width && asset.height
|
||||||
|
? `${asset.width} × ${asset.height}`
|
||||||
|
: t(mediaMessages.noDimensions)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MediaMetadata
|
||||||
|
label={t(mediaMessages.fileName)}
|
||||||
|
value={asset.name}
|
||||||
|
/>
|
||||||
|
<MediaMetadata
|
||||||
|
label={t(mediaMessages.uploadedAt)}
|
||||||
|
value={new Date(asset.createdAt).toLocaleString()}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={favoritePending}
|
||||||
|
onClick={() => onFavorite(asset)}
|
||||||
|
>
|
||||||
|
<HeartIcon
|
||||||
|
className={asset.favorite ? "fill-current" : undefined}
|
||||||
|
/>
|
||||||
|
{asset.favorite
|
||||||
|
? t(mediaMessages.unfavorite)
|
||||||
|
: t(mediaMessages.favoriteAction)}
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
nativeButton={false}
|
||||||
|
variant="outline"
|
||||||
|
render={
|
||||||
|
<a
|
||||||
|
href={resolveUrl(asset)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon />
|
||||||
|
{t(mediaMessages.viewOriginal)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deletePending}
|
||||||
|
onClick={() => onDelete(asset)}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
{t(mediaMessages.delete)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaMetadata({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||||
|
<dd className={cn("mt-1 font-medium wrap-break-word tabular-nums")}>
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<MediaAsset["id"]>
|
||||||
|
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 (
|
||||||
|
<div className="columns-2 gap-3 p-3 sm:columns-3 lg:columns-4 2xl:columns-5">
|
||||||
|
{assets.map((asset) => {
|
||||||
|
const selected = selectedIds.has(asset.id)
|
||||||
|
const active = activeId === asset.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={asset.id}
|
||||||
|
type="button"
|
||||||
|
data-media-id={asset.id}
|
||||||
|
aria-label={`${t(mediaMessages.select)} ${asset.name}`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => {
|
||||||
|
onActivate(asset)
|
||||||
|
onToggle?.(asset)
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => onDetails(asset)}
|
||||||
|
className={cn(
|
||||||
|
"group relative mb-3 block w-full break-inside-avoid overflow-hidden rounded-lg bg-muted text-left ring-offset-2 ring-offset-background transition-shadow outline-none focus-visible:ring-3 focus-visible:ring-ring",
|
||||||
|
(active || selected) && "ring-2 ring-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{asset.kind === "image" ? (
|
||||||
|
<img
|
||||||
|
src={resolveUrl(asset)}
|
||||||
|
alt={asset.name}
|
||||||
|
loading="lazy"
|
||||||
|
className="block min-h-24 w-full bg-muted object-cover"
|
||||||
|
style={
|
||||||
|
asset.width && asset.height
|
||||||
|
? { aspectRatio: `${asset.width} / ${asset.height}` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative aspect-video min-h-28 bg-black"
|
||||||
|
style={
|
||||||
|
asset.width && asset.height
|
||||||
|
? { aspectRatio: `${asset.width} / ${asset.height}` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
src={resolveUrl(asset)}
|
||||||
|
muted
|
||||||
|
preload="metadata"
|
||||||
|
className="size-full object-cover"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 grid place-items-center bg-black/10">
|
||||||
|
<FilmIcon className="size-8 text-white drop-shadow-md" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-linear-to-t from-black/75 to-transparent px-2 pt-8 pb-2 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||||
|
<span className="min-w-0 truncate text-xs">{asset.name}</span>
|
||||||
|
{asset.favorite && (
|
||||||
|
<HeartIcon className="size-3.5 shrink-0 fill-current" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<span className="absolute top-2 right-2 grid size-6 place-items-center rounded-full bg-primary text-primary-foreground shadow">
|
||||||
|
<CheckIcon className="size-4" strokeWidth={3} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(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<MediaFilter>(
|
||||||
|
allowedKinds?.length === 1 ? allowedKinds[0] : "all"
|
||||||
|
)
|
||||||
|
const [folder, setFolder] = React.useState<MediaFolderSelection>()
|
||||||
|
const [keyword, setKeyword] = React.useState("")
|
||||||
|
const deferredKeyword = React.useDeferredValue(keyword.trim())
|
||||||
|
const [assets, setAssets] = React.useState<readonly MediaAsset[]>([])
|
||||||
|
const [assetPage, setAssetPage] = React.useState(1)
|
||||||
|
const [hasNextPage, setHasNextPage] = React.useState(false)
|
||||||
|
const [assetError, setAssetError] = React.useState<Error>()
|
||||||
|
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
|
||||||
|
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||||
|
const [folders, setFolders] = React.useState<readonly MediaFolder[]>([])
|
||||||
|
const [storageTargets, setStorageTargets] = React.useState<
|
||||||
|
readonly MediaStorageTarget[]
|
||||||
|
>([DEFAULT_STORAGE_TARGET])
|
||||||
|
const [activeAsset, setActiveAsset] = React.useState<MediaAsset>()
|
||||||
|
const [contextAsset, setContextAsset] = React.useState<MediaAsset>()
|
||||||
|
const [detailOpen, setDetailOpen] = React.useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<MediaAsset>()
|
||||||
|
const [folderDeleteTarget, setFolderDeleteTarget] =
|
||||||
|
React.useState<MediaFolder>()
|
||||||
|
const [renameTarget, setRenameTarget] = React.useState<MediaAsset>()
|
||||||
|
const [folderDialog, setFolderDialog] = React.useState<FolderDialogState>()
|
||||||
|
const [pendingOperation, setPendingOperation] = React.useState<string>()
|
||||||
|
|
||||||
|
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 <Value,>(
|
||||||
|
name: string,
|
||||||
|
operation: () => Promise<Value>,
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
const target =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest<HTMLElement>("[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 (
|
||||||
|
<>
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"grid min-h-0 overflow-hidden rounded-lg border bg-background md:grid-cols-[14rem_minmax(0,1fr)]",
|
||||||
|
footer && "grid-rows-[minmax(0,1fr)_auto]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MediaSidebar
|
||||||
|
filter={filter}
|
||||||
|
folder={folder}
|
||||||
|
folders={folders}
|
||||||
|
allowedKinds={allowedKinds}
|
||||||
|
onFilterChange={showFilter}
|
||||||
|
onFolderChange={showFolder}
|
||||||
|
onCreateFolder={(parentId) =>
|
||||||
|
setFolderDialog({ mode: "create", parentId })
|
||||||
|
}
|
||||||
|
onRenameFolder={(item) =>
|
||||||
|
setFolderDialog({ folder: item, mode: "rename" })
|
||||||
|
}
|
||||||
|
onDeleteFolder={setFolderDeleteTarget}
|
||||||
|
className="hidden min-h-0 md:flex"
|
||||||
|
/>
|
||||||
|
<main className="flex min-h-0 min-w-0 flex-col">
|
||||||
|
<div className="flex min-h-14 items-center gap-2 border-b p-2">
|
||||||
|
<label className="relative min-w-0 flex-1">
|
||||||
|
<SearchIcon className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="ps-9"
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="md:hidden"
|
||||||
|
aria-label={t(mediaMessages.createFolder)}
|
||||||
|
onClick={() =>
|
||||||
|
setFolderDialog({ mode: "create", parentId: null })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FolderPlusIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={() => requestUpload(localTarget)}
|
||||||
|
>
|
||||||
|
{isUploading && uploadTargetRef.current.id === localTarget.id ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UploadIcon />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{t(mediaMessages.serverUpload)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
{cloudTargets.length === 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isUploading}
|
||||||
|
onClick={() => requestUpload(cloudTargets[0])}
|
||||||
|
>
|
||||||
|
<CloudUploadIcon />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{t(mediaMessages.cloudUpload)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{cloudTargets.length > 1 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUploading && uploadTargetRef.current.kind === "cloud" ? (
|
||||||
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CloudUploadIcon />
|
||||||
|
)}
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{t(mediaMessages.cloudUpload)}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon data-icon="inline-end" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{t(mediaMessages.chooseStorage)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{cloudTargets.map((target) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={target.id}
|
||||||
|
onClick={() => requestUpload(target)}
|
||||||
|
>
|
||||||
|
<CloudIcon />
|
||||||
|
{target.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={
|
||||||
|
allowedKinds?.length === 1 && allowedKinds[0] === "image"
|
||||||
|
? "image/*"
|
||||||
|
: MEDIA_ACCEPT
|
||||||
|
}
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files ?? [])
|
||||||
|
if (files.length) void uploadFiles(files)
|
||||||
|
event.target.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MediaFilterTabs
|
||||||
|
filter={filter}
|
||||||
|
folder={folder}
|
||||||
|
folders={folders}
|
||||||
|
allowedKinds={allowedKinds}
|
||||||
|
onFilterChange={showFilter}
|
||||||
|
onFolderChange={showFolder}
|
||||||
|
/>
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<MediaContextMenu
|
||||||
|
asset={contextAsset}
|
||||||
|
folders={folders}
|
||||||
|
storageTargets={storageTargets}
|
||||||
|
onUpload={requestUpload}
|
||||||
|
onDetails={showDetails}
|
||||||
|
onFavorite={(asset) => void updateFavorite(asset)}
|
||||||
|
onRename={setRenameTarget}
|
||||||
|
onMove={(asset, folderId) => void moveAsset(asset, folderId)}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="min-h-full"
|
||||||
|
onContextMenuCapture={selectContextAsset}
|
||||||
|
>
|
||||||
|
{isLoadingAssets ? (
|
||||||
|
<MediaEmpty
|
||||||
|
icon={<LoaderCircleIcon className="animate-spin" />}
|
||||||
|
title={t(mediaMessages.loading)}
|
||||||
|
/>
|
||||||
|
) : assetError ? (
|
||||||
|
<MediaEmpty
|
||||||
|
description={assetError.message}
|
||||||
|
title={t(mediaMessages.loadError)}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void refreshAssets()}
|
||||||
|
>
|
||||||
|
{t(mediaMessages.retry)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : assets.length ? (
|
||||||
|
<>
|
||||||
|
<MediaGrid
|
||||||
|
assets={assets}
|
||||||
|
activeId={activeAsset?.id}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
resolveUrl={resolveUrl}
|
||||||
|
onActivate={setActiveAsset}
|
||||||
|
onToggle={
|
||||||
|
selectionMode === "none" ? undefined : toggleSelection
|
||||||
|
}
|
||||||
|
onDetails={showDetails}
|
||||||
|
/>
|
||||||
|
{hasNextPage && (
|
||||||
|
<div className="flex justify-center p-4 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
>
|
||||||
|
{isLoadingMore && (
|
||||||
|
<LoaderCircleIcon className="animate-spin" />
|
||||||
|
)}
|
||||||
|
{t(mediaMessages.loadMore)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<MediaEmpty
|
||||||
|
icon={<UploadIcon />}
|
||||||
|
title={t(mediaMessages.emptyTitle)}
|
||||||
|
description={
|
||||||
|
deferredKeyword
|
||||||
|
? t(mediaMessages.emptySearchDescription)
|
||||||
|
: t(mediaMessages.emptyDescription)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MediaContextMenu>
|
||||||
|
</ScrollArea>
|
||||||
|
</main>
|
||||||
|
{footer && (
|
||||||
|
<footer className="col-span-full border-t bg-background p-3">
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MediaDetailDialog
|
||||||
|
asset={activeAsset}
|
||||||
|
open={detailOpen}
|
||||||
|
resolveUrl={resolveUrl}
|
||||||
|
favoritePending={pendingOperation === "favorite"}
|
||||||
|
deletePending={pendingOperation === "deleteAsset"}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
onFavorite={(asset) => void updateFavorite(asset)}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
|
/>
|
||||||
|
<MediaFolderDialog
|
||||||
|
open={Boolean(renameTarget)}
|
||||||
|
title={t(mediaMessages.renameMedia)}
|
||||||
|
description={t(mediaMessages.renameMediaDescription)}
|
||||||
|
initialName={renameName.baseName}
|
||||||
|
maxLength={255 - renameName.extension.length}
|
||||||
|
lockedSuffix={renameName.extension}
|
||||||
|
pending={pendingOperation === "rename"}
|
||||||
|
onOpenChange={(open) => !open && setRenameTarget(undefined)}
|
||||||
|
onSubmit={(name) => void saveRename(name)}
|
||||||
|
/>
|
||||||
|
<MediaFolderDialog
|
||||||
|
open={Boolean(folderDialog)}
|
||||||
|
title={t(
|
||||||
|
folderDialog?.mode === "rename"
|
||||||
|
? mediaMessages.renameFolder
|
||||||
|
: mediaMessages.createFolder
|
||||||
|
)}
|
||||||
|
description={t(mediaMessages.folderOnlyClassifies)}
|
||||||
|
initialName={
|
||||||
|
folderDialog?.mode === "rename" ? folderDialog.folder.name : ""
|
||||||
|
}
|
||||||
|
pending={pendingOperation === "folder"}
|
||||||
|
onOpenChange={(open) => !open && setFolderDialog(undefined)}
|
||||||
|
onSubmit={(name) => void saveFolder(name)}
|
||||||
|
/>
|
||||||
|
<MediaConfirmDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
pending={pendingOperation === "deleteAsset"}
|
||||||
|
title={t(mediaMessages.deleteAssetTitle)}
|
||||||
|
description={t(mediaMessages.deleteAssetDescription)}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||||
|
onConfirm={() => void deleteAsset()}
|
||||||
|
/>
|
||||||
|
<MediaConfirmDialog
|
||||||
|
open={Boolean(folderDeleteTarget)}
|
||||||
|
pending={pendingOperation === "deleteFolder"}
|
||||||
|
title={t(mediaMessages.deleteFolderTitle)}
|
||||||
|
description={t(mediaMessages.deleteFolderDescription)}
|
||||||
|
onOpenChange={(open) => !open && setFolderDeleteTarget(undefined)}
|
||||||
|
onConfirm={() => void deleteFolder()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaEmpty({
|
||||||
|
action,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
action?: React.ReactNode
|
||||||
|
description?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Empty className="min-h-72">
|
||||||
|
<EmptyContent>
|
||||||
|
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
||||||
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
|
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||||
|
{action}
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={pending}>
|
||||||
|
{t(mediaMessages.cancel)}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{t(mediaMessages.delete)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<readonly MediaAsset[]>(initialSelection)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) setSelected(initialSelection)
|
||||||
|
}, [initialSelection, open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="flex h-[min(88dvh,54rem)] max-h-[calc(100dvh-2rem)] flex-col gap-3 p-4 sm:max-w-[min(94vw,86rem)]"
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{t(mediaMessages.selectMedia)}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(mediaMessages.selectMediaDescription)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<MediaLibrary
|
||||||
|
allowedKinds={allowedKinds}
|
||||||
|
selectionMode={multiple ? "multiple" : "single"}
|
||||||
|
selectedAssets={selected}
|
||||||
|
onSelectionChange={setSelected}
|
||||||
|
className="min-h-0 flex-1"
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t(mediaMessages.selectedCount, { count: selected.length })}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t(mediaMessages.cancel)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm(
|
||||||
|
selected.map((asset) => {
|
||||||
|
const url =
|
||||||
|
adapter.resolveReference?.(asset) ??
|
||||||
|
defaultMediaReference(asset)
|
||||||
|
return url === asset.url ? asset : { ...asset, url }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
onOpenChange(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(mediaMessages.useSelected)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<I18nProvider locale="en">
|
||||||
|
<MediaProvider adapter={adapter}>{children}</MediaProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("media blocks", () => {
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
it("loads and uploads media only through the provider adapter", async () => {
|
||||||
|
const adapter = createAdapter()
|
||||||
|
const { container } = render(
|
||||||
|
<MediaTestProvider adapter={adapter}>
|
||||||
|
<MediaLibrary />
|
||||||
|
</MediaTestProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByRole("img", { name: "product.png" })).toBeTruthy()
|
||||||
|
const fileInput =
|
||||||
|
container.querySelector<HTMLInputElement>('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(
|
||||||
|
<MediaTestProvider adapter={adapter}>
|
||||||
|
<MediaPickerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => undefined}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
</MediaTestProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, MessageDescriptor>
|
||||||
@@ -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 (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-col border-e bg-muted/15 py-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h2 className="px-4 py-2 text-sm font-semibold">
|
||||||
|
{t(mediaMessages.library)}
|
||||||
|
</h2>
|
||||||
|
<nav aria-label={t(mediaMessages.library)} className="px-3">
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{visibleFilters(allowedKinds).map(({ icon, message, value }) => (
|
||||||
|
<li key={value}>
|
||||||
|
<SidebarButton
|
||||||
|
active={folder === undefined && filter === value}
|
||||||
|
icon={icon}
|
||||||
|
label={t(message)}
|
||||||
|
onClick={() => onFilterChange(value)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<SidebarButton
|
||||||
|
active={folder === "unclassified"}
|
||||||
|
icon={UnlinkIcon}
|
||||||
|
label={t(mediaMessages.unclassified)}
|
||||||
|
onClick={() => onFolderChange("unclassified")}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-4 flex min-h-0 flex-1 flex-col border-t pt-3">
|
||||||
|
<div className="flex items-center px-4 py-1">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t(mediaMessages.folder)}
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="ms-auto"
|
||||||
|
aria-label={t(mediaMessages.createFolder)}
|
||||||
|
onClick={() => onCreateFolder(null)}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
aria-label={t(mediaMessages.folder)}
|
||||||
|
className="min-h-0 overflow-y-auto px-3"
|
||||||
|
>
|
||||||
|
{rootFolders.length ? (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{rootFolders.map((rootFolder) => (
|
||||||
|
<FolderItem
|
||||||
|
key={rootFolder.id}
|
||||||
|
folder={rootFolder}
|
||||||
|
folders={folders}
|
||||||
|
depth={0}
|
||||||
|
activeFolder={folder}
|
||||||
|
onSelect={onFolderChange}
|
||||||
|
onCreateFolder={onCreateFolder}
|
||||||
|
onRename={onRenameFolder}
|
||||||
|
onDelete={onDeleteFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-4 text-start text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onCreateFolder(null)}
|
||||||
|
>
|
||||||
|
{t(mediaMessages.noFolders)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaFilterTabs({
|
||||||
|
allowedKinds,
|
||||||
|
filter,
|
||||||
|
folder,
|
||||||
|
folders,
|
||||||
|
onFilterChange,
|
||||||
|
onFolderChange,
|
||||||
|
}: Omit<
|
||||||
|
MediaSidebarProps,
|
||||||
|
"className" | "onCreateFolder" | "onDeleteFolder" | "onRenameFolder"
|
||||||
|
>) {
|
||||||
|
const t = useTranslate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 overflow-x-auto border-b p-2 md:hidden">
|
||||||
|
{visibleFilters(allowedKinds).map(({ icon: Icon, message, value }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={folder === undefined && filter === value}
|
||||||
|
onClick={() => onFilterChange(value)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-xs",
|
||||||
|
folder === undefined && filter === value
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{t(message)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<select
|
||||||
|
aria-label={t(mediaMessages.folder)}
|
||||||
|
className="h-8 max-w-40 rounded-lg border bg-background px-2 text-xs outline-none"
|
||||||
|
value={
|
||||||
|
folder === undefined
|
||||||
|
? ""
|
||||||
|
: folder === "unclassified"
|
||||||
|
? "unclassified"
|
||||||
|
: String(folder)
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value
|
||||||
|
onFolderChange(
|
||||||
|
next === ""
|
||||||
|
? undefined
|
||||||
|
: next === "unclassified"
|
||||||
|
? "unclassified"
|
||||||
|
: (folders.find((item) => String(item.id) === next)?.id ?? next)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t(mediaMessages.allMedia)}</option>
|
||||||
|
<option value="unclassified">{t(mediaMessages.unclassified)}</option>
|
||||||
|
{folders.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{" ".repeat(getMediaFolderDepth(item, folders))}
|
||||||
|
{getMediaFolderPath(item, folders)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarButton({
|
||||||
|
active,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
label: string
|
||||||
|
onClick: VoidFunction
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center gap-2 rounded-lg px-3 text-start text-sm transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/30",
|
||||||
|
active && "bg-primary text-primary-foreground hover:bg-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li>
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex h-9 items-center rounded-lg transition-colors hover:bg-muted",
|
||||||
|
active && "bg-secondary text-secondary-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasChildren ? (
|
||||||
|
<CollapsibleTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group/folder-toggle relative ms-2 grid size-7 shrink-0 place-items-center rounded-md outline-none hover:bg-muted-foreground/10 focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||||
|
aria-label={folder.name}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Folder className="size-4 transition-opacity group-hover/folder-toggle:opacity-0 group-focus-visible/folder-toggle:opacity-0" />
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn(
|
||||||
|
"absolute size-4 opacity-0 transition-[rotate,opacity] group-hover/folder-toggle:opacity-100 group-focus-visible/folder-toggle:opacity-100",
|
||||||
|
open && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
) : (
|
||||||
|
<span className="ms-2 grid size-7 shrink-0 place-items-center">
|
||||||
|
<Folder className="size-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t(mediaMessages.selectFolder, { name: folder.name })}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 self-stretch py-0 ps-1 pe-1.5 text-start text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||||
|
onClick={() => onSelect(folder.id)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
|
||||||
|
{(folder.assetCount ?? 0) > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{folder.assetCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="me-1 opacity-0 group-hover:opacity-100 aria-expanded:opacity-100"
|
||||||
|
aria-label={folder.name}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{depth < 2 && (
|
||||||
|
<DropdownMenuItem onClick={() => onCreateFolder(folder.id)}>
|
||||||
|
<PlusIcon />
|
||||||
|
{t(mediaMessages.newChildFolder)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem onClick={() => onRename(folder)}>
|
||||||
|
<PencilIcon />
|
||||||
|
{t(mediaMessages.rename)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDelete(folder)}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
{t(mediaMessages.delete)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
{hasChildren && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
<ul className="mt-1 space-y-1 ps-4">
|
||||||
|
{children.map((child) => (
|
||||||
|
<FolderItem
|
||||||
|
key={child.id}
|
||||||
|
folder={child}
|
||||||
|
folders={folders}
|
||||||
|
depth={depth + 1}
|
||||||
|
activeFolder={activeFolder}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onCreateFolder={onCreateFolder}
|
||||||
|
onRename={onRename}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</Collapsible>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<MediaFolder>
|
||||||
|
deleteAsset(id: MediaId): Promise<void>
|
||||||
|
deleteFolder(id: MediaId): Promise<void>
|
||||||
|
listAssets(query: MediaAssetQuery): Promise<MediaAssetPage>
|
||||||
|
listFolders(): Promise<readonly MediaFolder[]>
|
||||||
|
listStorageTargets?(): Promise<readonly MediaStorageTarget[]>
|
||||||
|
moveAsset(id: MediaId, folderId: MediaId | null): Promise<MediaAsset>
|
||||||
|
renameAsset(id: MediaId, name: string): Promise<MediaAsset>
|
||||||
|
updateFavorite(id: MediaId, favorite: boolean): Promise<MediaAsset>
|
||||||
|
updateFolder(input: UpdateMediaFolderInput): Promise<MediaFolder>
|
||||||
|
upload(input: MediaUploadInput): Promise<readonly MediaAsset[]>
|
||||||
|
|
||||||
|
/** 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"
|
||||||
|
}
|
||||||
@@ -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<MediaId>([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<MediaId>([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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user