192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
import * as React from "react"
|
||
import {
|
||
ExternalLinkIcon,
|
||
FilmIcon,
|
||
HeartIcon,
|
||
ImageIcon,
|
||
Trash2Icon,
|
||
} from "@workspace/ui/components/icon"
|
||
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>
|
||
)
|
||
}
|