2026-07-31 16:04:15 +08:00
|
|
|
import * as React from "react"
|
|
|
|
|
import {
|
|
|
|
|
ClipboardCopyIcon,
|
|
|
|
|
CloudUploadIcon,
|
|
|
|
|
CopyIcon,
|
|
|
|
|
FolderIcon,
|
|
|
|
|
FolderInputIcon,
|
|
|
|
|
HeartIcon,
|
|
|
|
|
InfoIcon,
|
|
|
|
|
LinkIcon,
|
|
|
|
|
PencilIcon,
|
|
|
|
|
Trash2Icon,
|
|
|
|
|
UploadIcon,
|
2026-09-20 15:52:22 +08:00
|
|
|
} from "@workspace/ui/components/icon"
|
2026-07-31 16:04:15 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 15:52:22 +08:00
|
|
|
function runAfterClose(action: VoidFunction) {
|
|
|
|
|
window.setTimeout(action)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 16:04:15 +08:00
|
|
|
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 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>
|
|
|
|
|
)
|
|
|
|
|
}
|