240 lines
9.0 KiB
TypeScript
240 lines
9.0 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 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
|
|
previewSize?: "normal" | "large"
|
|
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[]
|
|
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>, size: "normal" | "large" = "normal") {
|
|
const margin = 16
|
|
const previewWidth = Math.min(size === "large" ? 720 : 520, window.innerWidth - margin * 2)
|
|
const previewHeight = Math.min(size === "large" ? 880 : 760, window.innerHeight - margin * 2)
|
|
let left = event.clientX + 18
|
|
let top = event.clientY + 18
|
|
if (left + previewWidth > window.innerWidth - margin) left = event.clientX - previewWidth - 18
|
|
if (top + previewHeight > window.innerHeight - margin) top = window.innerHeight - previewHeight - margin
|
|
return { left: Math.max(margin, left), top: Math.max(margin, top) }
|
|
}
|
|
|
|
export function MediaAssetTile({
|
|
kind = "image",
|
|
src,
|
|
poster,
|
|
href,
|
|
alt = "",
|
|
title,
|
|
label,
|
|
meta,
|
|
previewDetail,
|
|
className = "",
|
|
mediaClassName = "",
|
|
objectFit = "contain",
|
|
previewObjectFit,
|
|
previewClassName = "",
|
|
previewSize = "normal",
|
|
selected = false,
|
|
disabled = false,
|
|
busy = false,
|
|
topLeft,
|
|
topRight,
|
|
bottom,
|
|
emptyText,
|
|
onClick,
|
|
onDelete,
|
|
deleteLabel = "删除素材",
|
|
deleting = false,
|
|
deleteDisabled = false,
|
|
actions = [],
|
|
disablePreview = false,
|
|
}: MediaAssetTileProps) {
|
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
|
const mediaSrc = src || poster || ""
|
|
const canPreview = !!mediaSrc && !disablePreview
|
|
const fit = mediaObjectClass(objectFit)
|
|
const previewFit = mediaObjectClass(previewObjectFit ?? objectFit)
|
|
const previewWidthClass = previewSize === "large" ? "w-[min(720px,calc(100vw-32px))]" : "w-[min(520px,calc(100vw-32px))]"
|
|
const previewMaxHeightClass = previewSize === "large" ? "max-h-[min(84vh,860px)]" : "max-h-[min(76vh,720px)]"
|
|
const previewMediaHeightClass = previewSize === "large" ? "max-h-[min(82vh,840px)]" : "max-h-[min(74vh,700px)]"
|
|
|
|
const updatePreview = (event: ReactMouseEvent<HTMLElement>) => {
|
|
if (!canPreview) return
|
|
setPosition(previewPosition(event, previewSize))
|
|
}
|
|
|
|
const media = kind === "video" && src ? (
|
|
<video src={src} poster={poster} muted playsInline 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-[12000] ${previewWidthClass} 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 }}
|
|
>
|
|
<div className={`flex ${previewMaxHeightClass} 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={`${previewMediaHeightClass} max-w-full ${previewFit}`}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={mediaSrc}
|
|
alt=""
|
|
className={`${previewMediaHeightClass} 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 opacity-0 transition 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>
|
|
)
|
|
}
|