Files
20260512-skg-tk/web/components/media-asset-tile.tsx

235 lines
8.5 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
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>) {
const margin = 16
const previewWidth = Math.min(520, window.innerWidth - margin * 2)
const previewHeight = Math.min(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 = "",
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 updatePreview = (event: ReactMouseEvent<HTMLElement>) => {
if (!canPreview) return
setPosition(previewPosition(event))
}
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-[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 }}
>
<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 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>
)
}