278 lines
14 KiB
TypeScript
278 lines
14 KiB
TypeScript
"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`;
|
||
}
|