819 lines
26 KiB
TypeScript
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>
|
|
)
|
|
}
|