2026-07-31 16:04:15 +08:00
|
|
|
|
import * as React from "react"
|
|
|
|
|
|
import {
|
|
|
|
|
|
ExternalLinkIcon,
|
|
|
|
|
|
FilmIcon,
|
|
|
|
|
|
HeartIcon,
|
|
|
|
|
|
ImageIcon,
|
|
|
|
|
|
Trash2Icon,
|
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 { 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|