Files
simple-react-app-kit/packages/blocks/src/blocks/media/media-library.tsx
T
2026-09-20 15:52:22 +08:00

819 lines
26 KiB
TypeScript

import * as React from "react"
import {
ChevronDownIcon,
CloudIcon,
CloudUploadIcon,
FolderPlusIcon,
LoaderCircleIcon,
SearchIcon,
UploadIcon,
} from "@workspace/ui/components/icon"
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 {
getMediaFolderPath,
isMediaFile,
mediaKindForFile,
MEDIA_ACCEPT,
splitMediaName,
} from "./utils"
const PAGE_SIZE = 40
const EMPTY_ASSETS: readonly MediaAsset[] = []
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, 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>
)
}