Files
nysoure/frontend/src/components/gallery.tsx
2025-11-28 19:55:35 +08:00

432 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
MdOutlineChevronLeft,
MdOutlineChevronRight,
MdOutlineClose,
} from "react-icons/md";
import { network } from "../network/network.ts";
import Badge from "./badge.tsx";
export default function Gallery({
images,
nsfw,
}: {
images: number[];
nsfw: number[];
}) {
const [currentIndex, setCurrentIndex] = useState(0);
const [direction, setDirection] = useState(0); // 方向1=向右,-1=向左
const [isHovered, setIsHovered] = useState(false);
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
// 预加载下一张图片
useEffect(() => {
if (!images || images.length <= 1) return;
const nextIndex = (currentIndex + 1) % images.length;
const nextImageUrl = network.getImageUrl(images[nextIndex]);
const img = new Image();
img.src = nextImageUrl;
}, [currentIndex, images]);
if (!images || images.length === 0) {
return <></>;
}
const goToPrevious = () => {
setDirection(-1);
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const goToNext = () => {
setDirection(1);
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};
const goToIndex = (index: number) => {
setDirection(index > currentIndex ? 1 : -1);
setCurrentIndex(index);
};
if (nsfw == null) {
nsfw = [];
}
// 如果图片数量超过8张显示数字而不是圆点
const showDots = images.length <= 8;
return (
<>
<GalleryFullscreen
dialogRef={dialogRef}
images={images}
nsfw={nsfw}
currentIndex={currentIndex}
direction={direction}
goToPrevious={goToPrevious}
goToNext={goToNext}
setDirection={setDirection}
setCurrentIndex={setCurrentIndex}
/>
<div
className="relative w-full overflow-hidden rounded-xl bg-base-100-tr82 shadow-sm"
style={{ aspectRatio: "16/9" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片区域 */}
<div
ref={containerRef}
className="w-full h-full relative"
onClick={() => {
dialogRef.current?.showModal();
}}
>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={currentIndex}
className="absolute inset-0 w-full h-full"
variants={{
enter: (dir: number) => ({
x: dir > 0 ? width : -width,
}),
center: {
x: 0,
transition: { duration: 0.3, ease: "linear" },
},
exit: (dir: number) => ({
x: dir > 0 ? -width : width,
transition: { duration: 0.3, ease: "linear" },
}),
}}
initial="enter"
animate="center"
exit="exit"
custom={direction}
>
<GalleryImage
src={network.getImageUrl(images[currentIndex])}
nfsw={nsfw.includes(images[currentIndex])}
/>
</motion.div>
</AnimatePresence>
)}
</div>
{/* 左右按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={goToPrevious}
>
<MdOutlineChevronLeft size={28} />
</button>
<button
className={`absolute right-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={goToNext}
>
<MdOutlineChevronRight size={28} />
</button>
</>
)}
{/* 底部指示器 */}
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
{showDots ? (
/* 圆点指示器 */
<div className="flex gap-2">
{images.map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-primary w-4"
: "bg-base-content/30 hover:bg-base-content/50"
}`}
onClick={() => goToIndex(index)}
aria-label={`Go to image ${index + 1}`}
/>
))}
</div>
) : (
/* 数字指示器 */
<div className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
)}
</div>
</>
);
}
function GalleryFullscreen({
dialogRef,
images,
nsfw,
currentIndex,
direction,
goToPrevious,
goToNext,
setDirection,
setCurrentIndex,
}: {
dialogRef: React.RefObject<HTMLDialogElement | null>;
images: number[];
nsfw: number[];
currentIndex: number;
direction: number;
goToPrevious: () => void;
goToNext: () => void;
setDirection: (direction: number) => void;
setCurrentIndex: (index: number) => void;
}) {
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const thumbnailContainerRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<number | null>(null);
const [isHovered, setIsHovered] = useState(true);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
console.log(containerRef.current.clientWidth);
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
useEffect(() => {
const handleMouseMove = () => {
setIsHovered(true);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
}, 2000);
};
if (dialogRef.current?.open) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("touchstart", handleMouseMove);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("touchstart", handleMouseMove);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, [dialogRef.current?.open, setIsHovered]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (dialogRef.current?.open) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevious();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNext();
} else if (e.key === "Escape") {
dialogRef.current?.close();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [dialogRef, goToPrevious, goToNext]);
useEffect(() => {
if (thumbnailContainerRef.current && dialogRef.current?.open) {
const thumbnail = thumbnailContainerRef.current.children[currentIndex] as HTMLElement;
if (thumbnail) {
thumbnail.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
}
}, [currentIndex, dialogRef]);
return (
<dialog
ref={dialogRef}
onClick={() => {
dialogRef.current?.close();
}}
className="modal"
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={containerRef}
className="modal-box w-full h-full max-h-screen max-w-screen p-4 bg-transparent shadow-none relative overflow-clip"
>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={`fullscreen-${currentIndex}`}
className="absolute inset-0 w-full h-full"
variants={{
enter: (dir: number) => ({
x: dir > 0 ? width : -width,
}),
center: {
x: 0,
transition: { duration: 0.3, ease: "linear" },
},
exit: (dir: number) => ({
x: dir > 0 ? -width : width,
transition: { duration: 0.3, ease: "linear" },
}),
}}
initial="enter"
animate="center"
exit="exit"
custom={direction}
>
<img
src={network.getImageUrl(images[currentIndex])}
alt=""
className="w-full h-full object-contain rounded-xl select-none"
/>
</motion.div>
</AnimatePresence>
)}
{/* 全屏模式下的左右切换按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
>
<MdOutlineChevronLeft size={24} />
</button>
<button
className={`absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<MdOutlineChevronRight size={24} />
</button>
{/* 图片缩略图列表 */}
<div
className={`absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={(e) => e.stopPropagation()}
>
<div
ref={thumbnailContainerRef}
className="flex gap-2 overflow-x-auto max-w-[80vw] px-2 py-2 bg-base-100/60 rounded-xl scrollbar-thin scrollbar-thumb-base-content/30 scrollbar-track-transparent"
>
{images.map((imageId, index) => (
<button
key={index}
className={`flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden transition-all ${
index === currentIndex
? "ring-2 ring-primary scale-110 "
: `${nsfw.includes(imageId) ? "blur-sm hover:blur-none" : "opacity-60 hover:opacity-100"}`
}`}
onClick={(e) => {
e.stopPropagation();
const newDirection = index > currentIndex ? 1 : -1;
setDirection(newDirection);
setCurrentIndex(index);
}}
>
<img
src={network.getResampledImageUrl(imageId)}
alt={`Thumbnail ${index + 1}`}
className={`w-full h-full object-cover select-none`}
/>
</button>
))}
</div>
</div>
{/* 关闭按钮 */}
<button
className={`absolute top-4 right-4 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors`}
onClick={(e) => {
e.stopPropagation();
dialogRef.current?.close();
}}
>
<MdOutlineClose size={24} />
</button>
</>
)}
</div>
</dialog>
);
}
function GalleryImage({ src, nfsw }: { src: string; nfsw: boolean }) {
const [show, setShow] = useState(!nfsw);
return (
<div className="relative w-full h-full">
<img
src={src}
alt=""
className={`w-full h-full object-cover transition-all duration-300 ${!show ? "blur-xl" : ""}`}
/>
{!show && (
<>
<div
className="absolute inset-0 bg-base-content/20 cursor-pointer"
onClick={(event) => {
setShow(true);
event.stopPropagation();
}}
/>
<div className="absolute top-4 left-4">
<Badge className="badge-error shadow-lg">NSFW</Badge>
</div>
</>
)}
</div>
);
}