Update WBBR frontend template collection
This commit is contained in:
277
app/image-host/page.tsx
Normal file
277
app/image-host/page.tsx
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user