249 lines
8.6 KiB
TypeScript
249 lines
8.6 KiB
TypeScript
'use client'
|
||
|
||
import Image from 'next/image'
|
||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import type { WorkReviewImageGroup } from '@/types/work-review'
|
||
|
||
const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
|
||
|
||
function normalizeImageUrl(value: string) {
|
||
const url = String(value || '').trim()
|
||
if (!url || !/^https?:\/\//i.test(url)) return url
|
||
try {
|
||
const parsed = new URL(url)
|
||
return PROXY_HOSTS.has(parsed.hostname.toLowerCase())
|
||
? `/api/proxy-media?url=${encodeURIComponent(url)}`
|
||
: url
|
||
} catch {
|
||
return url
|
||
}
|
||
}
|
||
|
||
function normalizeGroups(groups: WorkReviewImageGroup[], imageUrls: string[]): WorkReviewImageGroup[] {
|
||
if (groups.length > 0) {
|
||
return groups.map((group, index) => ({
|
||
...group,
|
||
sourceName: group.sourceName || `作品图片 ${index + 1}`,
|
||
previewUrl: normalizeImageUrl(group.previewUrl || group.slices?.[0]?.url || ''),
|
||
slices: (group.slices || []).map((slice) => ({
|
||
...slice,
|
||
url: normalizeImageUrl(slice.url),
|
||
})),
|
||
})).filter((group) => Boolean(group.previewUrl))
|
||
}
|
||
|
||
return imageUrls.map((url, index) => ({
|
||
sourceName: `作品图片 ${index + 1}`,
|
||
previewUrl: normalizeImageUrl(url),
|
||
previewWidth: 1200,
|
||
previewHeight: 900,
|
||
width: 1200,
|
||
height: 900,
|
||
slices: [{ url: normalizeImageUrl(url), width: 1200, height: 900 }],
|
||
}))
|
||
}
|
||
|
||
export function WorkReviewImageGallery({
|
||
groups,
|
||
imageUrls = [],
|
||
compact = false,
|
||
}: {
|
||
groups: WorkReviewImageGroup[]
|
||
imageUrls?: string[]
|
||
compact?: boolean
|
||
}) {
|
||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||
const displayGroups = useMemo(() => normalizeGroups(groups || [], imageUrls || []), [groups, imageUrls])
|
||
|
||
if (displayGroups.length === 0) {
|
||
return (
|
||
<div className="rounded-lg border border-dashed border-[#cfd7e3] bg-[#fafbfc] px-4 py-8 text-center text-[12px] font-semibold text-[#7b8494]">
|
||
暂无作品图片
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className={`grid gap-2 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 md:grid-cols-3'}`}>
|
||
{displayGroups.map((group, index) => (
|
||
<button
|
||
key={group.imageId || `${group.sourceName}-${index}`}
|
||
type="button"
|
||
onClick={() => setActiveIndex(index)}
|
||
className="group overflow-hidden rounded-lg border border-[#e1e7ef] bg-[#f6f8fb] text-left transition-colors hover:border-[#8ab9ff]"
|
||
aria-label={`查看${group.sourceName}`}
|
||
>
|
||
<div className={`relative overflow-hidden ${compact ? 'aspect-[4/3]' : 'aspect-[4/3]'}`}>
|
||
<Image
|
||
src={group.previewUrl}
|
||
alt={group.sourceName}
|
||
fill
|
||
unoptimized
|
||
className="object-cover object-top transition-transform duration-300 group-hover:scale-[1.02]"
|
||
/>
|
||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2.5 pb-2 pt-7">
|
||
<div className="truncate text-[11px] font-semibold text-white">{group.sourceName}</div>
|
||
</div>
|
||
{group.slices.length > 1 && (
|
||
<span className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white">
|
||
长图 · {group.slices.length} 段
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<WorkReviewImageViewer
|
||
group={activeIndex == null ? null : displayGroups[activeIndex]}
|
||
index={activeIndex}
|
||
total={displayGroups.length}
|
||
onClose={() => setActiveIndex(null)}
|
||
onPrevious={() => setActiveIndex((current) => (
|
||
current == null ? null : (current - 1 + displayGroups.length) % displayGroups.length
|
||
))}
|
||
onNext={() => setActiveIndex((current) => (
|
||
current == null ? null : (current + 1) % displayGroups.length
|
||
))}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function WorkReviewImageViewer({
|
||
group,
|
||
index,
|
||
total,
|
||
onClose,
|
||
onPrevious,
|
||
onNext,
|
||
}: {
|
||
group: WorkReviewImageGroup | null
|
||
index: number | null
|
||
total: number
|
||
onClose: () => void
|
||
onPrevious: () => void
|
||
onNext: () => void
|
||
}) {
|
||
const [portalReady, setPortalReady] = useState(false)
|
||
const dialogRef = useRef<HTMLDivElement | null>(null)
|
||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||
|
||
useEffect(() => setPortalReady(true), [])
|
||
|
||
useEffect(() => {
|
||
if (!group) return
|
||
const previousOverflow = document.body.style.overflow
|
||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||
document.body.style.overflow = 'hidden'
|
||
const frame = window.requestAnimationFrame(() => closeRef.current?.focus())
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault()
|
||
onClose()
|
||
} else if (event.key === 'ArrowLeft' && total > 1) {
|
||
event.preventDefault()
|
||
onPrevious()
|
||
} else if (event.key === 'ArrowRight' && total > 1) {
|
||
event.preventDefault()
|
||
onNext()
|
||
} else if (event.key === 'Tab') {
|
||
const buttons = Array.from(dialogRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') || [])
|
||
if (buttons.length === 0) return
|
||
const first = buttons[0]
|
||
const last = buttons[buttons.length - 1]
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault()
|
||
last.focus()
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault()
|
||
first.focus()
|
||
}
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKeyDown)
|
||
return () => {
|
||
window.cancelAnimationFrame(frame)
|
||
window.removeEventListener('keydown', onKeyDown)
|
||
document.body.style.overflow = previousOverflow
|
||
previousFocus?.focus()
|
||
}
|
||
}, [group, onClose, onNext, onPrevious, total])
|
||
|
||
if (!group || index == null || !portalReady) return null
|
||
|
||
return createPortal(
|
||
<div
|
||
ref={dialogRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`查看${group.sourceName}`}
|
||
tabIndex={-1}
|
||
className="fixed inset-0 z-[1200] flex flex-col bg-black/[0.94] text-white"
|
||
onClick={onClose}
|
||
>
|
||
<div className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-white/10 px-4" onClick={(event) => event.stopPropagation()}>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[13px] font-semibold">{group.sourceName}</div>
|
||
<div className="mt-0.5 text-[11px] text-white/55">{index + 1} / {total}</div>
|
||
</div>
|
||
<button
|
||
ref={closeRef}
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-[27px] font-light hover:bg-white/20"
|
||
aria-label="关闭图片预览"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-3 sm:px-6"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
{total > 1 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={onPrevious}
|
||
className="fixed left-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-2xl hover:bg-black/70"
|
||
aria-label="上一张"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onNext}
|
||
className="fixed right-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-2xl hover:bg-black/70"
|
||
aria-label="下一张"
|
||
>
|
||
›
|
||
</button>
|
||
</>
|
||
)}
|
||
<div className="mx-auto max-w-[1200px] overflow-hidden rounded bg-white">
|
||
{(group.slices.length > 0 ? group.slices : [{
|
||
url: group.previewUrl,
|
||
width: group.previewWidth,
|
||
height: group.previewHeight,
|
||
}]).map((slice, sliceIndex) => (
|
||
<Image
|
||
key={`${slice.url}-${sliceIndex}`}
|
||
src={slice.url}
|
||
alt={`${group.sourceName}${sliceIndex + 1}`}
|
||
width={slice.width}
|
||
height={slice.height}
|
||
unoptimized
|
||
loading="lazy"
|
||
className="block h-auto w-full"
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|