This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/image-host/page.tsx
2026-08-27 20:15:24 +08:00

278 lines
14 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.

"use client";
import Link from "next/link";
import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
import galleryData from "../data/image-host.json";
import styles from "./ImageHost.module.css";
import cardStyles from "./ImageHostCard.module.css";
type ImageItem = {
id: string;
title: string;
src: string;
category: string;
ratio: string;
position: string;
width: number;
height: number;
size: string;
format: string;
uploadedAt: string;
views: number;
tags: string[];
local?: boolean;
};
type AlbumItem = {
id: string;
title: string;
description: string;
category: string;
coverId: string;
imageIds: string[];
updatedAt: string;
tags: string[];
};
type DataShape = {
categories: string[];
images: ImageItem[];
albums: AlbumItem[];
feed: { kind: "image" | "album"; id: string }[];
};
type GalleryItem =
| { key: string; kind: "image"; image: ImageItem; albumId?: string }
| { key: string; kind: "album"; album: AlbumItem; cover: ImageItem };
type ViewState =
| { type: "gallery" }
| { type: "album"; id: string }
| { type: "image"; id: string; albumId?: string };
const data = galleryData as DataShape;
export default function ImageHostPage() {
const uploadRef = useRef<HTMLInputElement>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const localUrlsRef = useRef<string[]>([]);
const [view, setView] = useState<ViewState>({ type: "gallery" });
const [category, setCategory] = useState("全部");
const [query, setQuery] = useState("");
const [uploadedImages, setUploadedImages] = useState<ImageItem[]>([]);
const [quickPreview, setQuickPreview] = useState<GalleryItem | null>(null);
const [armingKey, setArmingKey] = useState<string | null>(null);
const [toast, setToast] = useState("");
const allImages = useMemo(() => [...uploadedImages, ...data.images], [uploadedImages]);
const imageMap = useMemo(() => new Map(allImages.map((image) => [image.id, image])), [allImages]);
const feedItems = useMemo(() => {
const uploaded: GalleryItem[] = uploadedImages.map((image) => ({ key: `image-${image.id}`, kind: "image", image }));
const bundled = data.feed.flatMap<GalleryItem>((entry) => {
if (entry.kind === "image") {
const image = imageMap.get(entry.id);
return image ? [{ key: `image-${image.id}`, kind: "image", image }] : [];
}
const album = data.albums.find((item) => item.id === entry.id);
const cover = album ? imageMap.get(album.coverId) : undefined;
return album && cover ? [{ key: `album-${album.id}`, kind: "album", album, cover }] : [];
});
return [...uploaded, ...bundled];
}, [imageMap, uploadedImages]);
const filteredItems = useMemo(() => feedItems.filter((item) => {
const itemCategory = item.kind === "image" ? item.image.category : item.album.category;
const searchable = item.kind === "image"
? `${item.image.title} ${item.image.tags.join(" ")} ${item.image.category}`
: `${item.album.title} ${item.album.description} ${item.album.tags.join(" ")} ${item.album.category}`;
return (category === "全部" || itemCategory === category) && searchable.toLowerCase().includes(query.trim().toLowerCase());
}), [feedItems, category, query]);
const currentAlbum = view.type === "album" ? data.albums.find((album) => album.id === view.id) ?? null : null;
const albumImages = currentAlbum ? currentAlbum.imageIds.map((id) => imageMap.get(id)).filter((image): image is ImageItem => Boolean(image)) : [];
const currentImage = view.type === "image" ? imageMap.get(view.id) ?? null : null;
useEffect(() => () => {
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
localUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
}, []);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 1900);
};
const clearPreviewTimer = () => {
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
previewTimerRef.current = null;
};
const armPreview = (item: GalleryItem) => {
clearPreviewTimer();
setQuickPreview(null);
setArmingKey(item.key);
previewTimerRef.current = window.setTimeout(() => {
setQuickPreview(item);
setArmingKey(null);
}, 3000);
};
const disarmPreview = () => {
clearPreviewTimer();
setArmingKey(null);
setQuickPreview(null);
};
const changeCategory = (value: string) => {
setCategory(value);
setView({ type: "gallery" });
window.scrollTo({ top: 0, behavior: "smooth" });
};
const openItem = (item: GalleryItem) => {
disarmPreview();
setView(item.kind === "album" ? { type: "album", id: item.album.id } : { type: "image", id: item.image.id, albumId: item.albumId });
window.scrollTo({ top: 0, behavior: "smooth" });
};
const goBack = () => {
if (view.type === "image" && view.albumId) setView({ type: "album", id: view.albumId });
else setView({ type: "gallery" });
window.scrollTo({ top: 0, behavior: "smooth" });
};
const uploadImages = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []).filter((file) => file.type.startsWith("image/"));
if (!files.length) return;
const today = new Date().toISOString().slice(0, 10);
const additions = files.map<ImageItem>((file, index) => {
const url = URL.createObjectURL(file);
localUrlsRef.current.push(url);
return {
id: `local-${Date.now()}-${index}`,
title: file.name.replace(/\.[^.]+$/, ""),
src: url,
category: "动漫",
ratio: index % 2 ? "4 / 5" : "3 / 2",
position: "center",
width: 0,
height: 0,
size: formatBytes(file.size),
format: file.type.split("/")[1]?.toUpperCase() || "IMAGE",
uploadedAt: today,
views: 0,
tags: ["本地预览", "刚刚上传"],
local: true,
};
});
setUploadedImages((current) => [...additions, ...current]);
setView({ type: "gallery" });
setCategory("全部");
event.target.value = "";
notify(`已添加 ${additions.length} 张本地图片`);
};
const copyImageUrl = async (image: ImageItem) => {
const value = image.local ? image.src : `${window.location.origin}${image.src}`;
try {
await navigator.clipboard.writeText(value);
notify("图片地址已复制");
} catch {
notify("当前浏览器无法复制地址");
}
};
return (
<div className={styles.page}>
<header className={styles.header}>
<button className={styles.brand} onClick={() => setView({ type: "gallery" })} aria-label="返回图床首页"><span></span><div><strong>LumaBox</strong><small>IMAGE HOST</small></div></button>
<label className={styles.search}><span></span><input value={query} onChange={(event) => setQuery(event.target.value)} onFocus={() => setView({ type: "gallery" })} placeholder="搜索图片、标签或图集" />{query && <button onClick={() => setQuery("")} aria-label="清空搜索">×</button>}</label>
<div className={styles.headerActions}><Link href="/"></Link><button className={styles.uploadButton} onClick={() => uploadRef.current?.click()}> </button><input ref={uploadRef} type="file" accept="image/*" multiple onChange={uploadImages} /></div>
</header>
<nav className={styles.categoryBar} aria-label="图片分类">
<div>{data.categories.map((item) => <button key={item} className={category === item && view.type === "gallery" ? styles.categoryActive : ""} onClick={() => changeCategory(item)}>{item}</button>)}</div>
<p><span> 3 </span></p>
</nav>
{view.type === "gallery" && <main className={styles.galleryPage}>
<section className={styles.hero}>
<div><p>YOUR VISUAL LIBRARY</p><h1></h1><span> · · </span></div>
<div className={styles.summary}><span><b>{feedItems.length}</b><small></small></span><span><b>{data.albums.length}</b><small></small></span><span><b>3s</b><small></small></span></div>
</section>
<div className={styles.resultLine}><div><strong>{category === "全部" ? "最近上传" : category}</strong><span>{filteredItems.length} </span></div>{(query || category !== "全部") && <button onClick={() => { setQuery(""); setCategory("全部"); }}></button>}</div>
{filteredItems.length > 0 ? <GalleryGrid items={filteredItems} armingKey={armingKey} onArm={armPreview} onDisarm={disarmPreview} onOpen={openItem} /> : <div className={styles.empty}><span></span><h2></h2><p></p><button onClick={() => { setQuery(""); setCategory("全部"); }}></button></div>}
</main>}
{view.type === "album" && currentAlbum && <main className={styles.albumPage}>
<button className={styles.backButton} onClick={goBack}> </button>
<section className={styles.albumHero}>
<div className={styles.albumCover}>{albumImages.slice(0, 3).map((image, index) => <img key={image.id} src={image.src} alt="" style={{ objectPosition: image.position, zIndex: 3 - index }} />)}</div>
<div className={styles.albumInfo}><p>ALBUM COLLECTION</p><h1>{currentAlbum.title}</h1><span>{currentAlbum.description}</span><div>{currentAlbum.tags.map((tag) => <b key={tag}>#{tag}</b>)}</div><small>{albumImages.length} · {currentAlbum.updatedAt}</small></div>
</section>
<div className={styles.resultLine}><div><strong></strong><span></span></div></div>
<GalleryGrid items={albumImages.map((image) => ({ key: `album-image-${currentAlbum.id}-${image.id}`, kind: "image", image, albumId: currentAlbum.id }))} armingKey={armingKey} onArm={armPreview} onDisarm={disarmPreview} onOpen={openItem} />
</main>}
{view.type === "image" && currentImage && <main className={styles.detailPage}>
<button className={styles.backButton} onClick={goBack}> {view.albumId ? "返回图集" : "返回图床"}</button>
<div className={styles.detailLayout}>
<figure className={styles.detailVisual}><img src={currentImage.src} alt={currentImage.title} /><figcaption> · {currentImage.format}</figcaption></figure>
<aside className={styles.detailInfo}>
<p>IMAGE DETAIL</p><h1>{currentImage.title}</h1>
<div className={styles.detailTags}>{currentImage.tags.map((tag) => <span key={tag}>#{tag}</span>)}</div>
<dl><div><dt></dt><dd>{currentImage.format}</dd></div><div><dt></dt><dd>{currentImage.width ? `${currentImage.width} × ${currentImage.height}` : "本地预览"}</dd></div><div><dt></dt><dd>{currentImage.size}</dd></div><div><dt></dt><dd>{currentImage.uploadedAt}</dd></div><div><dt></dt><dd>{currentImage.views.toLocaleString()}</dd></div><div><dt></dt><dd>{currentImage.category}</dd></div></dl>
<button className={styles.copyButton} onClick={() => copyImageUrl(currentImage)}></button>
<a className={styles.rawButton} href={currentImage.src} target="_blank" rel="noreferrer"> </a>
<p className={styles.detailHint}>使 JSON </p>
</aside>
</div>
</main>}
{quickPreview && <div className={styles.quickBackdrop} aria-hidden="true"><figure className={styles.quickPreview}><img src={quickPreview.kind === "image" ? quickPreview.image.src : quickPreview.cover.src} alt="" style={{ objectPosition: quickPreview.kind === "image" ? quickPreview.image.position : quickPreview.cover.position }} /><figcaption><span>{quickPreview.kind === "album" ? `图集 · ${quickPreview.album.imageIds.length}` : "图片快速预览"}</span><strong>{quickPreview.kind === "image" ? quickPreview.image.title : quickPreview.album.title}</strong><small></small></figcaption></figure></div>}
{toast && <div className={styles.toast}> {toast}</div>}
</div>
);
}
function GalleryGrid({ items, armingKey, onArm, onDisarm, onOpen }: {
items: GalleryItem[];
armingKey: string | null;
onArm: (item: GalleryItem) => void;
onDisarm: () => void;
onOpen: (item: GalleryItem) => void;
}) {
return <section className={styles.masonry} aria-label="图片卡片列表">{items.map((item) => {
const image = item.kind === "image" ? item.image : item.cover;
const title = item.kind === "image" ? item.image.title : item.album.title;
const tags = item.kind === "image" ? item.image.tags : item.album.tags;
return <button
key={item.key}
className={`${styles.card} ${cardStyles.card} ${item.kind === "album" ? styles.albumCard : ""}`}
onPointerEnter={() => onArm(item)}
onPointerMove={() => onArm(item)}
onPointerLeave={onDisarm}
onFocus={() => onArm(item)}
onBlur={onDisarm}
onClick={() => onOpen(item)}
>
<span className={`${styles.cardImage} ${cardStyles.cardImage}`} style={{ aspectRatio: image.ratio }}>
<img src={image.src} alt={title} style={{ objectPosition: image.position }} />
<span className={`${styles.cardBody} ${cardStyles.cardBody}`}>
{item.kind === "album" && <b className={`${styles.albumBadge} ${cardStyles.albumBadge}`}> {item.album.imageIds.length} </b>}
<strong>{title}</strong>
<small>{item.kind === "album" ? item.album.description : `${image.width || "本地"}${image.width ? ` × ${image.height}` : ""} · ${image.format}`}</small>
<span className={`${styles.cardFooter} ${cardStyles.cardFooter}`}><em>{tags.slice(0, 2).map((tag) => `#${tag}`).join(" ")}</em><b>{item.kind === "album" ? "进入图集" : `${image.views.toLocaleString()} 浏览`} </b></span>
</span>
{armingKey === item.key && <i className={styles.previewProgress} />}
</span>
</button>;
})}</section>;
}
function formatBytes(bytes: number) {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}