Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

277
app/image-host/page.tsx Normal file
View File

@@ -0,0 +1,277 @@
"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`;
}