251 lines
9.4 KiB
TypeScript
251 lines
9.4 KiB
TypeScript
"use client"
|
|
|
|
import { type MouseEvent as ReactMouseEvent, type ReactNode, useState } from "react"
|
|
import { createPortal } from "react-dom"
|
|
import { Film, Loader2, Trash2 } from "lucide-react"
|
|
|
|
type MediaAssetAction = {
|
|
key: string
|
|
label: string
|
|
icon: ReactNode
|
|
onClick: () => void
|
|
disabled?: boolean
|
|
busy?: boolean
|
|
tone?: "neutral" | "cyan" | "rose"
|
|
}
|
|
|
|
type MediaAssetPreviewPlacement = "auto" | "left" | "right"
|
|
|
|
type MediaAssetTileProps = {
|
|
kind?: "image" | "video"
|
|
src?: string
|
|
poster?: string
|
|
href?: string
|
|
alt?: string
|
|
title?: string
|
|
label?: ReactNode
|
|
meta?: ReactNode
|
|
previewDetail?: ReactNode
|
|
className?: string
|
|
mediaClassName?: string
|
|
objectFit?: "contain" | "cover"
|
|
previewObjectFit?: "contain" | "cover"
|
|
previewClassName?: string
|
|
previewPlacement?: MediaAssetPreviewPlacement
|
|
previewMaxWidth?: number
|
|
videoControls?: boolean
|
|
selected?: boolean
|
|
disabled?: boolean
|
|
busy?: boolean
|
|
topLeft?: ReactNode
|
|
topRight?: ReactNode
|
|
bottom?: ReactNode
|
|
emptyText?: string
|
|
onClick?: () => void
|
|
onDelete?: () => void
|
|
deleteLabel?: string
|
|
deleting?: boolean
|
|
deleteDisabled?: boolean
|
|
actions?: MediaAssetAction[]
|
|
actionsAlwaysVisible?: boolean
|
|
disablePreview?: boolean
|
|
}
|
|
|
|
const actionToneClass: Record<NonNullable<MediaAssetAction["tone"]>, string> = {
|
|
neutral: "border-white/18 bg-black/78 text-white/82 hover:border-white/42 hover:bg-white/12",
|
|
cyan: "border-cyan-100/35 bg-black/78 text-cyan-100 hover:border-cyan-100/70 hover:bg-cyan-500/25",
|
|
rose: "border-rose-100/35 bg-black/78 text-rose-100 hover:border-rose-100/70 hover:bg-rose-500/25",
|
|
}
|
|
|
|
function mediaObjectClass(fit: "contain" | "cover") {
|
|
return fit === "cover" ? "object-cover" : "object-contain"
|
|
}
|
|
|
|
function previewPosition(event: ReactMouseEvent<HTMLElement>, placement: MediaAssetPreviewPlacement, maxWidth: number) {
|
|
const margin = 16
|
|
const previewWidth = Math.min(maxWidth, window.innerWidth - margin * 2)
|
|
const previewHeight = Math.min(760, window.innerHeight - margin * 2)
|
|
let left = placement === "left" ? event.clientX - previewWidth - 18 : event.clientX + 18
|
|
let top = event.clientY + 18
|
|
if (placement === "auto" && left + previewWidth > window.innerWidth - margin) left = event.clientX - previewWidth - 18
|
|
if (placement === "right" && left + previewWidth > window.innerWidth - margin) left = window.innerWidth - previewWidth - margin
|
|
if (placement === "left" && left < margin) left = margin
|
|
if (top + previewHeight > window.innerHeight - margin) top = window.innerHeight - previewHeight - margin
|
|
return {
|
|
left: Math.max(margin, Math.min(left, window.innerWidth - previewWidth - margin)),
|
|
top: Math.max(margin, top),
|
|
width: previewWidth,
|
|
}
|
|
}
|
|
|
|
export function MediaAssetTile({
|
|
kind = "image",
|
|
src,
|
|
poster,
|
|
href,
|
|
alt = "",
|
|
title,
|
|
label,
|
|
meta,
|
|
previewDetail,
|
|
className = "",
|
|
mediaClassName = "",
|
|
objectFit = "contain",
|
|
previewObjectFit,
|
|
previewClassName = "",
|
|
previewPlacement = "auto",
|
|
previewMaxWidth = 520,
|
|
videoControls = false,
|
|
selected = false,
|
|
disabled = false,
|
|
busy = false,
|
|
topLeft,
|
|
topRight,
|
|
bottom,
|
|
emptyText,
|
|
onClick,
|
|
onDelete,
|
|
deleteLabel = "删除素材",
|
|
deleting = false,
|
|
deleteDisabled = false,
|
|
actions = [],
|
|
actionsAlwaysVisible = false,
|
|
disablePreview = false,
|
|
}: MediaAssetTileProps) {
|
|
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
|
|
const mediaSrc = src || poster || ""
|
|
const canPreview = !!mediaSrc && !disablePreview
|
|
const fit = mediaObjectClass(objectFit)
|
|
const previewFit = mediaObjectClass(previewObjectFit ?? objectFit)
|
|
|
|
const updatePreview = (event: ReactMouseEvent<HTMLElement>) => {
|
|
if (!canPreview) return
|
|
setPosition(previewPosition(event, previewPlacement, previewMaxWidth))
|
|
}
|
|
|
|
const media = kind === "video" && src ? (
|
|
<video src={src} poster={poster} muted playsInline controls={videoControls} preload="metadata" className={`h-full w-full ${fit} ${mediaClassName}`} />
|
|
) : mediaSrc ? (
|
|
<img src={mediaSrc} alt={alt} className={`h-full w-full ${fit} ${mediaClassName}`} />
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-white/28">
|
|
<Film className="h-4 w-4" />
|
|
{emptyText ? <span className="ml-1 text-[10px]">{emptyText}</span> : null}
|
|
</div>
|
|
)
|
|
|
|
const interactiveClass = disabled ? "cursor-not-allowed opacity-55" : onClick || href ? "cursor-pointer" : ""
|
|
const bodyClass = `absolute inset-0 block h-full w-full overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70 ${interactiveClass}`
|
|
const body = href ? (
|
|
<a href={href} target="_blank" rel="noreferrer" className={bodyClass} onClick={(event) => { if (disabled) event.preventDefault() }}>
|
|
{media}
|
|
</a>
|
|
) : onClick ? (
|
|
<button type="button" disabled={disabled} onClick={onClick} className={bodyClass}>
|
|
{media}
|
|
</button>
|
|
) : (
|
|
<div className={bodyClass}>{media}</div>
|
|
)
|
|
|
|
const preview = position && canPreview && typeof document !== "undefined"
|
|
? createPortal(
|
|
<div
|
|
className={`pointer-events-none fixed z-[10000] w-[min(520px,calc(100vw-32px))] rounded-xl border border-white/15 bg-black/94 p-3 shadow-[0_28px_80px_rgba(0,0,0,0.72)] ${previewClassName}`}
|
|
style={{ left: position.left, top: position.top, width: position.width }}
|
|
>
|
|
<div className="flex max-h-[min(76vh,720px)] items-center justify-center overflow-hidden rounded-lg bg-black">
|
|
{kind === "video" && src ? (
|
|
<video
|
|
src={src}
|
|
poster={poster}
|
|
muted
|
|
loop
|
|
playsInline
|
|
autoPlay
|
|
preload="auto"
|
|
className={`max-h-[min(74vh,700px)] max-w-full ${previewFit}`}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={mediaSrc}
|
|
alt=""
|
|
className={`max-h-[min(74vh,700px)] max-w-full ${previewFit}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
{(label || meta || previewDetail) && (
|
|
<div className="mt-2 text-[11px] leading-snug text-white/66">
|
|
{(label || meta) && (
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="min-w-0 truncate">{label}</span>
|
|
{meta ? <span className="shrink-0 font-mono text-white/44">{meta}</span> : null}
|
|
</div>
|
|
)}
|
|
{previewDetail ? <div className="mt-1 text-white/58">{previewDetail}</div> : null}
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null
|
|
|
|
return (
|
|
<div
|
|
className={`group relative overflow-hidden rounded border bg-black transition ${selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/45"} ${className}`}
|
|
title={title}
|
|
onMouseEnter={updatePreview}
|
|
onMouseMove={updatePreview}
|
|
onMouseLeave={() => setPosition(null)}
|
|
>
|
|
{body}
|
|
{preview}
|
|
{topLeft ? <div className="pointer-events-none absolute left-1 top-1 z-10">{topLeft}</div> : null}
|
|
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
|
|
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
|
|
{(actions.length || onDelete) ? (
|
|
<div className={`absolute right-1 top-1 z-20 flex flex-col gap-0.5 transition ${actionsAlwaysVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"}`}>
|
|
{actions.map((action) => (
|
|
<button
|
|
key={action.key}
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
if (!action.disabled && !action.busy) action.onClick()
|
|
}}
|
|
disabled={action.disabled || action.busy}
|
|
className={`inline-flex h-5 w-5 items-center justify-center rounded-full border transition focus:outline-none focus:ring-1 focus:ring-cyan-100/70 disabled:cursor-not-allowed disabled:opacity-60 ${actionToneClass[action.tone ?? "neutral"]}`}
|
|
aria-label={action.label}
|
|
title={action.label}
|
|
>
|
|
{action.busy ? <Loader2 className="h-3 w-3 animate-spin" /> : action.icon}
|
|
</button>
|
|
))}
|
|
{onDelete ? (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
if (!deleteDisabled && !deleting) onDelete()
|
|
}}
|
|
disabled={deleteDisabled || deleting}
|
|
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-rose-100/35 bg-black/78 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
|
|
aria-label={deleteLabel}
|
|
title={deleteLabel}
|
|
>
|
|
{deleting ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{busy ? (
|
|
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/65">
|
|
<Loader2 className="h-4 w-4 animate-spin text-white/80" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|