Initial import

This commit is contained in:
adminroot committed 2026-08-21 19:23:22 +00:00
commit 02516abfd9
24 files changed
+8682

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
/pic/
/.pixhelf-cache/
/target/
/frontend/node_modules/
/frontend/dist/
*.log
Generated
+1110
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "pixhelf"
version = "0.1.0"
edition = "2024"
build = "build.rs"
[dependencies]
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["json", "query", "tokio"] }
blake3 = "1.8.7"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
mime_guess = "2.0.5"
natord = "1.0.9"
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.53.1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["compression-gzip", "fs", "set-header", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
walkdir = "2.5.0"
webp = "0.3.1"
[dev-dependencies]
tempfile = "3.27.0"
[profile.release]
lto = "thin"
strip = "symbols"
+41
View File
@@ -0,0 +1,41 @@
# Pixhelf
基于 Axum 和 React 的自托管图片画廊。React 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。
## 运行
默认从当前工作目录的 `./pic` 读取图片,并监听 `0.0.0.0:3002`:
```bash
./target/release/pixhelf
```
首次启动会先生成首批 60 张、最长边 720px 的 WebP 缩略图,然后开放 HTTP 服务。其余缩略图由后台任务持续处理;打开图片时,查看器会按需生成独立的最长边 2560px WebP 预览。新增、修改或删除图片会在定期扫描后自动同步。
缩略图和查看器预览缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。
## 构建
需要当前稳定版 Rust、Node.js 20+ 和 npm:
```bash
cd frontend
npm ci
cd ..
cargo build --release
```
`cargo build` 会自动执行 React 生产构建,并将结果嵌入 `target/release/pixhelf`。
## 参数
```text
--gallery-dir PATH 图库目录,默认 ./pic
--cache-dir PATH 缩略图和查看器预览缓存,默认 ./.pixhelf-cache/thumbnails
--listen HOST:PORT 监听地址,默认 0.0.0.0:3002
--initial-batch N 启动前生成数量,默认 60
--workers N 后台压缩并发数,默认按 CPU 自动选择 2-4
--scan-interval SEC 图库重新扫描间隔,默认 10 秒
```
支持 JPEG、PNG 和 WebP 文件。其他文件不会进入图库索引。
+29
View File
@@ -0,0 +1,29 @@
use std::{env, path::PathBuf, process::Command};
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory"));
let frontend_dir = manifest_dir.join("frontend");
for path in [
"frontend/index.html",
"frontend/package.json",
"frontend/package-lock.json",
"frontend/tsconfig.json",
"frontend/vite.config.ts",
"frontend/src",
] {
println!("cargo:rerun-if-changed={path}");
}
if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() {
return;
}
let status = Command::new("npm")
.args(["run", "build", "--silent"])
.current_dir(frontend_dir)
.status()
.expect("failed to run the frontend build; install Node.js and run `npm ci` in frontend/");
assert!(status.success(), "frontend build failed");
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f4f5f4" />
<meta name="description" content="Pixhelf 私人图片画廊" />
<title>Pixhelf</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1907
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "pixhelf-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false",
"visual-check": "node scripts/visual-check.mjs"
},
"dependencies": {
"lucide-react": "^0.539.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"playwright-core": "^1.62.1",
"typescript": "^5.9.2",
"vite": "^7.1.3"
}
}
+286
View File
@@ -0,0 +1,286 @@
import { chromium } from "playwright-core";
const executablePath =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ??
"/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome";
const baseUrl = process.env.PIXHELF_URL ?? "http://127.0.0.1:3002";
const browser = await chromium.launch({
executablePath,
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const results = [];
async function dispatchTouch(cdp, type, points) {
await cdp.send("Input.dispatchTouchEvent", {
type,
touchPoints: points.map((point) => ({
id: point.id,
x: point.x,
y: point.y,
radiusX: 5,
radiusY: 5,
force: 1,
})),
});
}
async function touchTap(cdp, point, pointerId = 1) {
await dispatchTouch(cdp, "touchStart", [{ ...point, id: pointerId }]);
await dispatchTouch(cdp, "touchEnd", []);
}
async function touchDrag(cdp, from, to, steps = 10, pointerId = 1) {
await dispatchTouch(cdp, "touchStart", [{ ...from, id: pointerId }]);
for (let step = 1; step <= steps; step += 1) {
await dispatchTouch(cdp, "touchMove", [{
id: pointerId,
x: from.x + (to.x - from.x) * (step / steps),
y: from.y + (to.y - from.y) * (step / steps),
}]);
}
await dispatchTouch(cdp, "touchEnd", []);
}
async function touchPinch(cdp, center, fromDistance, toDistance, steps = 10) {
const firstStart = { id: 1, x: center.x - fromDistance / 2, y: center.y };
const secondStart = { id: 2, x: center.x + fromDistance / 2, y: center.y };
await dispatchTouch(cdp, "touchStart", [firstStart, secondStart]);
for (let step = 1; step <= steps; step += 1) {
const distance = fromDistance + (toDistance - fromDistance) * (step / steps);
await dispatchTouch(cdp, "touchMove", [
{ id: 1, x: center.x - distance / 2, y: center.y },
{ id: 2, x: center.x + distance / 2, y: center.y },
]);
}
await dispatchTouch(cdp, "touchEnd", []);
}
try {
for (const target of [
{ name: "desktop", width: 1440, height: 1000 },
{ name: "mobile", width: 390, height: 844 },
]) {
const page = await browser.newPage({
viewport: { width: target.width, height: target.height },
deviceScaleFactor: target.name === "mobile" ? 3 : 1,
hasTouch: target.name === "mobile",
});
const browserErrors = [];
page.on("console", (message) => {
if (message.type() === "error") browserErrors.push(message.text());
});
page.on("pageerror", (error) => browserErrors.push(error.message));
await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForSelector("[data-image-id]", { timeout: 60_000 });
await page.waitForFunction(() => {
const visible = [...document.querySelectorAll("[data-image-id] img")].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight;
});
return visible.length >= 8 && visible.every((image) => image.complete && image.naturalWidth > 0);
}, undefined, { timeout: 60_000 });
await page.waitForTimeout(250);
await page.screenshot({ path: `/tmp/pixhelf-${target.name}.png` });
const layout = await page.evaluate(() => ({
viewport: [innerWidth, innerHeight],
bodyWidth: document.documentElement.scrollWidth,
cards: document.querySelectorAll("[data-image-id]").length,
masonryColumns: document.querySelectorAll(".masonry-column").length,
loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length,
brokenVisibleImages: [...document.images].filter((image) => {
const rect = image.getBoundingClientRect();
return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0;
}).length,
horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1,
}));
await page.locator("[data-image-id]").first().click();
await page.waitForSelector(".image-viewer");
await page.waitForFunction(() => {
const image = document.querySelector(".viewer-image-original");
return image instanceof HTMLImageElement &&
image.complete &&
image.naturalWidth > 0 &&
image.classList.contains("is-ready") &&
Number(getComputedStyle(image).opacity) > 0.98;
}, undefined, { timeout: 60_000 });
await page.waitForTimeout(250);
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` });
const viewerLayout = await page.evaluate(() => {
const viewer = document.querySelector(".image-viewer");
const frame = document.querySelector(".viewer-image-frame");
const image = document.querySelector(".viewer-image-original");
const transform = document.querySelector(".viewer-transform");
if (!viewer || !frame || !(image instanceof HTMLImageElement) || !transform) {
return { visible: false };
}
const rect = frame.getBoundingClientRect();
const imageStyle = getComputedStyle(image);
return {
visible: true,
frame: [rect.left, rect.top, rect.right, rect.bottom],
naturalSize: [image.naturalWidth, image.naturalHeight],
imageOpacity: imageStyle.opacity,
objectFit: imageStyle.objectFit,
noLegacyLightbox: document.querySelectorAll(".yarl__portal, .yarl__thumbnails_container").length === 0,
controlsVisible: viewer.getAttribute("data-controls-visible"),
transform: getComputedStyle(transform).transform,
};
});
const frame = await page.locator(".viewer-canvas").boundingBox();
if (!frame) throw new Error("viewer canvas is missing");
const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
const transform = page.locator(".viewer-transform");
await page.mouse.dblclick(center.x + 48, center.y - 32, { delay: 70 });
await page.waitForFunction(() => document.querySelector(".viewer-transform")?.getAttribute("data-zoomed") === "true");
const doubleClickZoom = await transform.evaluate((element) => getComputedStyle(element).transform);
await page.waitForTimeout(700);
const zoomedLayout = await page.evaluate(() => {
const frame = document.querySelector(".viewer-image-frame");
const transform = document.querySelector(".viewer-transform");
if (!frame || !transform) return { frame: [0, 0, 0, 0], scale: 0, rasterScale: 0 };
const rect = frame.getBoundingClientRect();
return {
frame: [rect.left, rect.top, rect.width, rect.height],
scale: Number(transform.getAttribute("data-scale") ?? 0),
rasterScale: Number(transform.getAttribute("data-raster-scale") ?? transform.getAttribute("data-rasterScale") ?? 0),
};
});
await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}-zoomed.png` });
await page.mouse.click(center.x, center.y);
await page.waitForTimeout(300);
const singleClickControls = await page.locator(".image-viewer").getAttribute("data-controls-visible");
await page.mouse.wheel(0, -360);
await page.waitForTimeout(350);
const wheelZoom = await transform.evaluate((element) => getComputedStyle(element).transform);
const counterBefore = await page.locator(".viewer-counter").textContent();
await page.keyboard.press("ArrowRight");
await page.waitForFunction((previous) => document.querySelector(".viewer-counter")?.textContent !== previous, counterBefore);
const counterAfter = await page.locator(".viewer-counter").textContent();
let touchChecks = null;
if (target.name === "mobile") {
const cdp = await page.context().newCDPSession(page);
const swipeFrame = await page.locator(".viewer-canvas").boundingBox();
if (!swipeFrame) throw new Error("mobile viewer canvas is missing");
const centerPoint = {
x: swipeFrame.x + swipeFrame.width / 2,
y: swipeFrame.y + swipeFrame.height / 2,
};
await page.keyboard.press("0");
await page.waitForTimeout(280);
const pinchBefore = await transform.getAttribute("data-scale");
await touchPinch(cdp, centerPoint, 82, 210);
await page.waitForTimeout(300);
const pinchAfter = await transform.getAttribute("data-scale");
await page.keyboard.press("0");
await page.waitForTimeout(280);
const touchCounterBefore = await page.locator(".viewer-counter").textContent();
await touchDrag(
cdp,
{ x: swipeFrame.x + swipeFrame.width * 0.78, y: centerPoint.y },
{ x: swipeFrame.x + swipeFrame.width * 0.2, y: centerPoint.y + 3 },
);
await page.waitForFunction(
(previous) => document.querySelector(".viewer-counter")?.textContent !== previous,
touchCounterBefore,
);
const touchCounterAfter = await page.locator(".viewer-counter").textContent();
const controlsBeforeTap = await page.locator(".image-viewer").getAttribute("data-controls-visible");
await touchTap(cdp, { x: 18, y: centerPoint.y });
await page.waitForTimeout(330);
const viewerStillOpenAfterBlankTap = await page.locator(".image-viewer").count();
const controlsAfterTap = await page.locator(".image-viewer").getAttribute("data-controls-visible");
await touchDrag(
cdp,
{ x: centerPoint.x, y: swipeFrame.y + swipeFrame.height * 0.34 },
{ x: centerPoint.x + 3, y: swipeFrame.y + swipeFrame.height * 0.7 },
);
await page.waitForSelector(".image-viewer", { state: "detached" });
touchChecks = {
pinchBefore: Number(pinchBefore),
pinchAfter: Number(pinchAfter),
touchCounterBefore,
touchCounterAfter,
controlsBeforeTap,
controlsAfterTap,
viewerStillOpenAfterBlankTap,
dismissDetached: true,
};
} else {
await page.keyboard.press("Escape");
await page.waitForSelector(".image-viewer", { state: "detached" });
}
const result = {
name: target.name,
...layout,
viewerLayout,
zoomedLayout,
doubleClickZoom,
wheelZoom,
singleClickControls,
counterBefore,
counterAfter,
touchChecks,
browserErrors,
};
results.push(result);
console.log(JSON.stringify(result));
const expectedColumns = target.name === "desktop" ? 5 : 2;
const viewerContained = viewerLayout.visible &&
viewerLayout.frame[0] >= -1 &&
viewerLayout.frame[1] >= -1 &&
viewerLayout.frame[2] <= target.width + 1 &&
viewerLayout.frame[3] <= target.height + 1;
if (
layout.horizontalOverflow ||
layout.brokenVisibleImages ||
layout.masonryColumns !== expectedColumns ||
!viewerContained ||
Number(viewerLayout.imageOpacity) < 0.98 ||
viewerLayout.objectFit !== "contain" ||
!viewerLayout.noLegacyLightbox ||
!doubleClickZoom.includes("matrix") ||
!wheelZoom.includes("matrix") ||
zoomedLayout.scale <= 1.01 ||
zoomedLayout.rasterScale <= 1.01 ||
zoomedLayout.frame[2] <= (viewerLayout.frame[2] - viewerLayout.frame[0]) * 1.1 ||
Math.abs(zoomedLayout.frame[2] / (viewerLayout.frame[2] - viewerLayout.frame[0]) - zoomedLayout.scale) > 0.08 ||
singleClickControls !== "false" ||
counterBefore === counterAfter ||
(target.name === "mobile" && (
!touchChecks ||
touchChecks.pinchAfter <= touchChecks.pinchBefore + 0.2 ||
touchChecks.touchCounterBefore === touchChecks.touchCounterAfter ||
touchChecks.viewerStillOpenAfterBlankTap !== 1 ||
touchChecks.controlsBeforeTap === touchChecks.controlsAfterTap ||
!touchChecks.dismissDetached
)) ||
browserErrors.length
) {
throw new Error(`visual check failed: ${JSON.stringify(result)}`);
}
await page.close();
}
} finally {
await browser.close();
}
console.log(JSON.stringify(results, null, 2));
+627
View File
@@ -0,0 +1,627 @@
import {
ArrowDownAZ,
Check,
Folder,
Image as ImageIcon,
Images,
LoaderCircle,
Menu,
PanelLeftClose,
RefreshCw,
Search,
X,
} from "lucide-react";
import {
type CSSProperties,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { getGallery, getImages, getStatus } from "./api";
import ImageViewer from "./ImageViewer";
import type {
GalleryImage,
GallerySummary,
SortMode,
ThumbnailStatus,
} from "./types";
const PAGE_SIZE = 60;
const MOBILE_PAGE_SIZE = 36;
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== "undefined" && window.matchMedia(query).matches,
);
useEffect(() => {
const media = window.matchMedia(query);
const update = () => setMatches(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [query]);
return matches;
}
function useDebounced<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delay);
return () => window.clearTimeout(timer);
}, [delay, value]);
return debounced;
}
function formatCount(value: number): string {
return new Intl.NumberFormat("zh-CN").format(value);
}
function App() {
const [summary, setSummary] = useState<GallerySummary | null>(null);
const [status, setStatus] = useState<ThumbnailStatus | null>(null);
const [images, setImages] = useState<GalleryImage[]>([]);
const [total, setTotal] = useState(0);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const [album, setAlbum] = useState("");
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortMode>("name-asc");
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [reloadToken, setReloadToken] = useState(0);
const compactLayout = useMediaQuery("(max-width: 720px)");
const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE;
const debouncedSearch = useDebounced(search.trim(), 250);
const activeQuery = `${album}\u0000${debouncedSearch}\u0000${sort}`;
const activeQueryRef = useRef(activeQuery);
activeQueryRef.current = activeQuery;
useLayoutEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
}, [album, debouncedSearch, sort]);
useEffect(() => {
if (!mobileNavOpen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setMobileNavOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", closeOnEscape);
};
}, [mobileNavOpen]);
useEffect(() => {
if (!compactLayout && mobileNavOpen) setMobileNavOpen(false);
}, [compactLayout, mobileNavOpen]);
useEffect(() => {
const controller = new AbortController();
getGallery(controller.signal)
.then(setSummary)
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : "无法读取图库");
}
});
return () => controller.abort();
}, [reloadToken]);
useEffect(() => {
let cancelled = false;
let timer = 0;
const poll = async () => {
try {
const next = await getStatus();
if (cancelled) return;
setStatus(next);
timer = window.setTimeout(poll, next.backgroundComplete ? 10000 : 1500);
} catch {
if (!cancelled) timer = window.setTimeout(poll, 5000);
}
};
void poll();
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, []);
useEffect(() => {
if (status && summary && status.total !== summary.total) {
void getGallery().then(setSummary).catch(() => undefined);
}
}, [status?.total, summary?.total]);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
setImages([]);
setNextOffset(null);
getImages(
{
album,
search: debouncedSearch,
sort,
offset: 0,
limit: pageSize,
},
controller.signal,
)
.then((page) => {
setImages(page.items);
setTotal(page.total);
setNextOffset(page.nextOffset);
})
.catch((reason: unknown) => {
if (!controller.signal.aborted) {
setError(reason instanceof Error ? reason.message : "无法读取图片");
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [album, debouncedSearch, pageSize, reloadToken, sort]);
const loadMore = useCallback(async () => {
if (nextOffset === null || loadingMore) return;
const queryAtStart = activeQueryRef.current;
setLoadingMore(true);
try {
const page = await getImages({
album,
search: debouncedSearch,
sort,
offset: nextOffset,
limit: pageSize,
});
if (queryAtStart !== activeQueryRef.current) return;
setImages((current) => [...current, ...page.items]);
setNextOffset(page.nextOffset);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "无法继续加载图片");
} finally {
setLoadingMore(false);
}
}, [album, debouncedSearch, loadingMore, nextOffset, pageSize, sort]);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || nextOffset === null) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) void loadMore();
},
{ rootMargin: compactLayout ? "280px 0px" : "600px 0px" },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [compactLayout, loadMore, nextOffset]);
const activeAlbum = summary?.albums.find((item) => item.path === album);
const title = activeAlbum?.name ?? "全部图片";
const selectedIndex = selectedId
? images.findIndex((image) => image.id === selectedId)
: -1;
const openViewer = useCallback((id: string) => {
setSelectedId(id);
}, []);
const closeViewer = useCallback(() => {
setSelectedId(null);
}, []);
const chooseAlbum = (path: string) => {
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
setAlbum(path);
setMobileNavOpen(false);
};
return (
<div className="app-shell">
<Header
status={status}
onOpenNavigation={() => setMobileNavOpen(true)}
navigationOpen={mobileNavOpen}
/>
<Sidebar
summary={summary}
activeAlbum={album}
onChoose={chooseAlbum}
mobileOpen={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
status={status}
/>
<main className="content">
<section className="content-heading">
<div className="title-block">
<p className="path-label">{album || "图库"}</p>
<div className="title-line">
<h1>{title}</h1>
<span>{formatCount(total)} 张</span>
</div>
</div>
<div className="toolbar">
<label className="search-field">
<Search aria-hidden="true" size={17} />
<input
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索文件名"
aria-label="搜索文件名"
enterKeyHint="search"
autoComplete="off"
/>
{search && (
<button
type="button"
className="clear-search"
onClick={() => setSearch("")}
aria-label="清空搜索"
title="清空搜索"
>
<X size={15} />
</button>
)}
</label>
<label className="sort-control">
<ArrowDownAZ aria-hidden="true" size={17} />
<select
value={sort}
onChange={(event) => setSort(event.target.value as SortMode)}
aria-label="图片排序"
>
<option value="name-asc">名称顺序</option>
<option value="name-desc">名称倒序</option>
<option value="newest">最近修改</option>
</select>
</label>
</div>
</section>
{error && (
<div className="error-banner" role="alert">
<span>{error}</span>
<button
type="button"
onClick={() => setReloadToken((value) => value + 1)}
aria-label="重新加载"
title="重新加载"
>
<RefreshCw size={17} />
</button>
</div>
)}
{loading ? (
<GallerySkeleton />
) : images.length ? (
<MasonryGallery images={images} onOpen={openViewer} />
) : (
<div className="empty-state">
<ImageIcon size={30} strokeWidth={1.6} />
<strong>没有找到图片</strong>
<span>请调整相册或搜索条件</span>
</div>
)}
<div ref={sentinelRef} className="load-sentinel" aria-live="polite">
{loadingMore && <LoaderCircle className="spin" size={21} aria-label="加载更多" />}
{!loading && nextOffset === null && images.length > 0 && (
<span>已显示全部 {formatCount(total)} 张图片</span>
)}
</div>
</main>
{selectedIndex >= 0 && (
<ImageViewer
images={images}
index={selectedIndex}
total={total}
onIndexChange={(index) => setSelectedId(images[index]?.id ?? null)}
onNeedMore={() => void loadMore()}
onClose={closeViewer}
/>
)}
</div>
);
}
function Header({
status,
onOpenNavigation,
navigationOpen,
}: {
status: ThumbnailStatus | null;
onOpenNavigation: () => void;
navigationOpen: boolean;
}) {
return (
<header className="topbar">
<button
type="button"
className="icon-button mobile-menu"
onClick={onOpenNavigation}
aria-label="打开相册导航"
aria-expanded={navigationOpen}
aria-controls="mobile-album-navigation"
title="打开相册导航"
>
<Menu size={20} />
</button>
<div className="brand">
<span className="brand-mark"><Images size={21} /></span>
<span>Pixhelf</span>
</div>
<StatusCompact status={status} />
</header>
);
}
function StatusCompact({ status }: { status: ThumbnailStatus | null }) {
if (!status) return <span className="status-compact muted">连接中</span>;
const complete = status.backgroundComplete;
return (
<div
className={`status-compact ${complete ? "complete" : ""}`}
title={complete ? "缩略图处理完成" : "正在后台处理缩略图"}
>
{complete ? <Check size={15} /> : <LoaderCircle className="spin" size={15} />}
<span>{formatCount(status.ready)} / {formatCount(status.total)}</span>
</div>
);
}
function Sidebar({
summary,
activeAlbum,
onChoose,
mobileOpen,
onClose,
status,
}: {
summary: GallerySummary | null;
activeAlbum: string;
onChoose: (path: string) => void;
mobileOpen: boolean;
onClose: () => void;
status: ThumbnailStatus | null;
}) {
const navigation = (
<>
<div className="sidebar-heading">
<span>相册</span>
<button
type="button"
className="icon-button sidebar-close"
onClick={onClose}
aria-label="关闭相册导航"
title="关闭相册导航"
>
<PanelLeftClose size={19} />
</button>
</div>
<nav className="album-nav" aria-label="相册">
<AlbumButton
label="全部图片"
count={summary?.total ?? 0}
active={!activeAlbum}
onClick={() => onChoose("")}
all
/>
{summary?.albums.map((item) => (
<AlbumButton
key={item.path}
label={item.name}
detail={item.path.includes("/") ? item.path : undefined}
count={item.count}
active={activeAlbum === item.path}
onClick={() => onChoose(item.path)}
/>
))}
</nav>
<SidebarProgress status={status} />
</>
);
return (
<>
<aside className="sidebar desktop-sidebar">{navigation}</aside>
{mobileOpen && (
<div className="mobile-nav-layer" role="presentation">
<button
className="mobile-nav-scrim"
type="button"
onClick={onClose}
aria-label="关闭相册导航"
/>
<aside
id="mobile-album-navigation"
className="sidebar mobile-sidebar"
role="dialog"
aria-modal="true"
aria-label="相册导航"
>
{navigation}
</aside>
</div>
)}
</>
);
}
function AlbumButton({
label,
detail,
count,
active,
onClick,
all = false,
}: {
label: string;
detail?: string;
count: number;
active: boolean;
onClick: () => void;
all?: boolean;
}) {
return (
<button
type="button"
className={`album-link ${active ? "active" : ""}`}
onClick={onClick}
aria-current={active ? "page" : undefined}
>
{all ? <Images size={17} /> : <Folder size={17} />}
<span className="album-copy">
<strong>{label}</strong>
{detail && <small>{detail}</small>}
</span>
<span className="album-count">{formatCount(count)}</span>
</button>
);
}
function SidebarProgress({ status }: { status: ThumbnailStatus | null }) {
if (!status) return null;
const percent = status.total ? Math.round((status.ready / status.total) * 100) : 100;
return (
<div className="sidebar-progress">
<div>
<span>{status.backgroundComplete ? "缩略图已就绪" : "后台处理中"}</span>
<strong>{percent}%</strong>
</div>
<progress max={status.total || 1} value={status.ready} />
{status.failed > 0 && <small>{status.failed} 个任务失败</small>}
</div>
);
}
function useColumnCount(ref: RefObject<HTMLDivElement | null>): number {
const [columns, setColumns] = useState(5);
useEffect(() => {
const element = ref.current;
if (!element) return;
const update = (width: number) => {
const minimumCardWidth = 218;
const gap = 6;
const next = Math.floor((width + gap) / (minimumCardWidth + gap));
setColumns(Math.min(6, Math.max(2, next)));
};
update(element.clientWidth);
const observer = new ResizeObserver(([entry]) => update(entry.contentRect.width));
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return columns;
}
function MasonryGallery({
images,
onOpen,
}: {
images: GalleryImage[];
onOpen: (id: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const columnCount = useColumnCount(ref);
const columns = useMemo(() => {
const result: { image: GalleryImage; index: number }[][] = Array.from(
{ length: columnCount },
() => [],
);
const heights = Array(columnCount).fill(0) as number[];
images.forEach((image, index) => {
const target = heights.indexOf(Math.min(...heights));
result[target].push({ image, index });
heights[target] += image.height / Math.max(image.width, 1) + 0.05;
});
return result;
}, [columnCount, images]);
return (
<div
ref={ref}
className="masonry"
style={{ "--columns": columnCount } as CSSProperties}
>
{columns.map((column, columnIndex) => (
<div className="masonry-column" key={columnIndex}>
{column.map(({ image, index }) => (
<ImageCard key={image.id} image={image} index={index} onOpen={onOpen} />
))}
</div>
))}
</div>
);
}
function ImageCard({
image,
index,
onOpen,
}: {
image: GalleryImage;
index: number;
onOpen: (id: string) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
return (
<button
type="button"
className="image-card"
onClick={() => onOpen(image.id)}
aria-label={`查看 ${image.name}`}
title={image.name}
data-image-id={image.id}
style={{ aspectRatio: `${image.width} / ${image.height}` }}
>
{!failed ? (
<img
src={image.thumbnailUrl}
alt={image.name}
loading={index < 10 ? "eager" : "lazy"}
decoding="async"
fetchPriority={index < 5 ? "high" : "auto"}
className={loaded ? "loaded" : ""}
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : (
<span className="image-fallback"><ImageIcon size={24} /></span>
)}
<span className="image-name">{image.name}</span>
</button>
);
}
function GallerySkeleton() {
const ratios = [1.4, 0.72, 1, 1.55, 0.8, 1.2, 0.67, 1.35, 0.9, 1.6, 0.76, 1.1];
return (
<div className="skeleton-grid" aria-label="正在加载图库">
{ratios.map((ratio, index) => (
<span key={index} style={{ aspectRatio: String(ratio) }} />
))}
</div>
);
}
export default App;
+855
View File
@@ -0,0 +1,855 @@
import {
ChevronLeft,
ChevronRight,
Download,
ImageOff,
LoaderCircle,
Maximize,
Minimize,
Minus,
Plus,
RefreshCw,
Scan,
X,
} from "lucide-react";
import {
type CSSProperties,
type SyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import type { GalleryImage } from "./types";
import { type Point, useViewerEngine } from "./viewer/useViewerEngine";
const DOUBLE_TAP_DELAY = 230;
const VIEWER_PREVIEW_EDGE = 2560;
type SourceStatus = "loading" | "ready" | "failed";
interface SourceState {
imageId: string;
status: SourceStatus;
}
interface PendingTap extends Point {
time: number;
pointerType: string;
}
interface RetryState {
imageId: string;
attempt: number;
}
interface ImageViewerProps {
images: GalleryImage[];
index: number;
total: number;
onIndexChange: (index: number) => void;
onNeedMore: () => void;
onClose: () => void;
}
interface ViewportSize {
width: number;
height: number;
}
function viewportSize(): ViewportSize {
return {
width: Math.round(window.visualViewport?.width ?? window.innerWidth),
height: Math.round(window.visualViewport?.height ?? window.innerHeight),
};
}
function useViewportSize(): ViewportSize {
const [size, setSize] = useState(viewportSize);
useEffect(() => {
let frame = 0;
const update = () => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => setSize(viewportSize()));
};
window.addEventListener("resize", update);
window.visualViewport?.addEventListener("resize", update);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
window.visualViewport?.removeEventListener("resize", update);
};
}, []);
return size;
}
function fittedSize(image: GalleryImage, viewport: ViewportSize) {
const compact = viewport.width <= 720;
const availableWidth = Math.max(1, viewport.width - (compact ? 8 : 72));
const availableHeight = Math.max(1, viewport.height - (compact ? 8 : 40));
const ratio = Math.min(
availableWidth / Math.max(image.width, 1),
availableHeight / Math.max(image.height, 1),
1,
);
return {
width: Math.max(1, Math.round(image.width * ratio)),
height: Math.max(1, Math.round(image.height * ratio)),
};
}
function maximumZoom(
image: GalleryImage,
frame: ViewportSize,
previewReady: boolean,
originalReady: boolean,
) {
if (!previewReady && !originalReady) return 1;
const sourceEdge = originalReady
? Math.max(image.width, image.height)
: Math.min(VIEWER_PREVIEW_EDGE, Math.max(image.width, image.height));
const nativeScale = sourceEdge / Math.max(frame.width, frame.height, 1);
return Math.max(1, Math.min(6, nativeScale));
}
function formatCount(value: number): string {
return new Intl.NumberFormat("zh-CN").format(value);
}
function formatBytes(value: number): string {
if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
export default function ImageViewer({
images,
index,
total,
onIndexChange,
onNeedMore,
onClose,
}: ImageViewerProps) {
const image = images[index];
const previous = images[index - 1] ?? null;
const next = images[index + 1] ?? null;
const viewport = useViewportSize();
const compact = viewport.width <= 720;
const frameSize = useMemo(() => fittedSize(image, viewport), [image, viewport]);
const slideSpan = viewport.width + (compact ? 12 : 28);
const [sourceState, setSourceState] = useState<SourceState>({
imageId: image.id,
status: "loading",
});
const [previewState, setPreviewState] = useState<SourceState>({
imageId: image.id,
status: "loading",
});
const [thumbnailState, setThumbnailState] = useState<SourceState>({
imageId: image.id,
status: "loading",
});
// Keep the large source out of the network queue while the sharp preview is
// being fetched. A short timeout keeps the original a reliable fallback on
// slow or unavailable preview endpoints.
const [originalGate, setOriginalGate] = useState({ imageId: image.id, open: false });
const [retryState, setRetryState] = useState<RetryState>({
imageId: image.id,
attempt: 0,
});
const sourceAttempt = retryState.imageId === image.id ? retryState.attempt : 0;
const [controlsVisible, setControlsVisible] = useState(true);
const [closing, setClosing] = useState(false);
const [fullscreen, setFullscreen] = useState(Boolean(document.fullscreenElement));
const [pendingNext, setPendingNext] = useState(false);
const [announcement, setAnnouncement] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const currentImageIdRef = useRef(image.id);
const sourceAttemptRef = useRef(sourceAttempt);
const closeTimerRef = useRef(0);
const controlsTimerRef = useRef(0);
const tapTimerRef = useRef(0);
const pendingTimerRef = useRef(0);
const pendingRetryRef = useRef(0);
const tapRef = useRef<PendingTap | null>(null);
const tapHandlerRef = useRef<(point: Point, pointerType: string) => void>(() => undefined);
currentImageIdRef.current = image.id;
sourceAttemptRef.current = sourceAttempt;
const originalReady = sourceState.imageId === image.id && sourceState.status === "ready";
const originalFailed = sourceState.imageId === image.id && sourceState.status === "failed";
const previewReady = previewState.imageId === image.id && previewState.status === "ready";
const thumbnailReady = thumbnailState.imageId === image.id && thumbnailState.status === "ready";
const originalEnabled = originalGate.imageId === image.id && originalGate.open;
const maxZoom = useMemo(
() => maximumZoom(
image,
frameSize,
previewReady,
originalReady,
),
[frameSize, image, originalReady, previewReady],
);
const clearTap = useCallback(() => {
window.clearTimeout(tapTimerRef.current);
tapTimerRef.current = 0;
tapRef.current = null;
}, []);
const scheduleControlsHide = useCallback(() => {
window.clearTimeout(controlsTimerRef.current);
controlsTimerRef.current = window.setTimeout(() => {
const root = rootRef.current;
if (root?.querySelector(".viewer-chrome:focus-within")) return;
setControlsVisible(false);
}, compact ? 1700 : 1400);
}, [compact]);
const revealControls = useCallback(() => {
setControlsVisible(true);
scheduleControlsHide();
}, [scheduleControlsHide]);
const requestOriginal = useCallback(() => {
setOriginalGate((current) =>
current.imageId === image.id && !current.open
? { imageId: image.id, open: true }
: current,
);
}, [image.id]);
const requestClose = useCallback(() => {
if (closing) return;
clearTap();
window.clearTimeout(controlsTimerRef.current);
setClosing(true);
if (document.fullscreenElement === rootRef.current) {
void document.exitFullscreen().catch(() => undefined);
}
closeTimerRef.current = window.setTimeout(onClose, 170);
}, [clearTap, closing, onClose]);
const handleNavigate = useCallback((direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= images.length) return;
clearTap();
setPendingNext(false);
onIndexChange(target);
revealControls();
}, [clearTap, images.length, index, onIndexChange, revealControls]);
const handleEdge = useCallback((direction: -1 | 1) => {
if (direction > 0 && index + 1 < total && !pendingNext) {
setPendingNext(true);
onNeedMore();
}
}, [index, onNeedMore, pendingNext, total]);
const engine = useViewerEngine({
imageId: image.id,
frameSize,
maximumZoom: maxZoom,
canPrevious: Boolean(previous),
canNext: Boolean(next),
onNavigate: handleNavigate,
onEdge: handleEdge,
onDismiss: requestClose,
onTap: (point, pointerType) => tapHandlerRef.current(point, pointerType),
onDragStart: clearTap,
onInteraction: () => {
revealControls();
requestOriginal();
},
});
const commitSingleTap = useCallback(() => {
setControlsVisible((visible) => {
if (visible) window.clearTimeout(controlsTimerRef.current);
else scheduleControlsHide();
return !visible;
});
}, [scheduleControlsHide]);
const handleTap = useCallback((point: Point, pointerType: string) => {
const now = performance.now();
const pending = tapRef.current;
const tapDelay = pointerType === "touch" ? DOUBLE_TAP_DELAY : 200;
const threshold = pointerType === "touch" ? 34 : 24;
const compatible = pending &&
pending.pointerType === pointerType &&
now - pending.time <= tapDelay + 40 &&
Math.hypot(pending.x - point.x, pending.y - point.y) <= threshold;
if (compatible) {
clearTap();
engine.toggleZoomAt(point);
return;
}
if (pending) {
window.clearTimeout(tapTimerRef.current);
commitSingleTap();
}
tapRef.current = { ...point, time: now, pointerType };
tapTimerRef.current = window.setTimeout(() => {
tapRef.current = null;
tapTimerRef.current = 0;
commitSingleTap();
}, tapDelay);
}, [clearTap, commitSingleTap, engine]);
tapHandlerRef.current = handleTap;
const canvasHandlers = {
onPointerDown: engine.handlePointerDown,
onPointerMove: engine.handlePointerMove,
onPointerUp: engine.handlePointerUp,
onPointerCancel: engine.handlePointerCancel,
onLostPointerCapture: engine.handleLostPointerCapture,
};
useEffect(() => {
setAnnouncement(`${image.name},第 ${index + 1} 张,共 ${total} 张`);
}, [image.id, image.name, index, total]);
useEffect(() => {
if (!pendingNext || index + 1 >= images.length) return;
let cancelled = false;
const advance = () => {
if (cancelled) return;
if (engine.navigate(1)) {
setPendingNext(false);
return;
}
pendingRetryRef.current = window.setTimeout(advance, 120);
};
advance();
return () => {
cancelled = true;
window.clearTimeout(pendingRetryRef.current);
};
}, [engine.navigate, images.length, index, pendingNext]);
useEffect(() => {
if (!pendingNext) return;
window.clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = window.setTimeout(() => setPendingNext(false), 8000);
return () => window.clearTimeout(pendingTimerRef.current);
}, [pendingNext]);
useEffect(() => {
if (index >= images.length - 4 && index + 1 < total) onNeedMore();
}, [images.length, index, onNeedMore, total]);
const handleOriginalLoad = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
const imageId = element.dataset.imageId;
const attempt = Number(element.dataset.sourceAttempt ?? -1);
try {
await element.decode();
} catch {
// A completed image can still reject decode on some WebKit versions.
}
if (
imageId &&
imageId === currentImageIdRef.current &&
attempt === sourceAttemptRef.current &&
element.isConnected &&
element.complete &&
element.naturalWidth > 0
) {
setSourceState({ imageId, status: "ready" });
}
}, []);
const handleThumbnailLoad = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
const imageId = event.currentTarget.dataset.imageId;
if (
imageId &&
imageId === currentImageIdRef.current &&
event.currentTarget.isConnected &&
event.currentTarget.complete &&
event.currentTarget.naturalWidth > 0
) {
setThumbnailState({ imageId, status: "ready" });
}
}, []);
const handlePreviewLoad = useCallback(async (event: SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
const imageId = element.dataset.imageId;
try {
await element.decode();
} catch {
// A completed image can still reject decode on some WebKit versions.
}
if (
imageId &&
imageId === currentImageIdRef.current &&
element.isConnected &&
element.complete &&
element.naturalWidth > 0
) {
setPreviewState({ imageId, status: "ready" });
}
}, []);
const handlePreviewError = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
const imageId = event.currentTarget.dataset.imageId;
if (imageId && imageId === currentImageIdRef.current && event.currentTarget.isConnected) {
setPreviewState({ imageId, status: "failed" });
}
}, []);
useEffect(() => {
setSourceState({ imageId: image.id, status: "loading" });
setPreviewState({ imageId: image.id, status: "loading" });
setThumbnailState({ imageId: image.id, status: "loading" });
setOriginalGate({ imageId: image.id, open: false });
const timer = window.setTimeout(() => {
setOriginalGate((current) =>
current.imageId === image.id ? { imageId: image.id, open: true } : current,
);
}, 350);
return () => window.clearTimeout(timer);
}, [image.id]);
useEffect(() => {
const previewFailed = previewState.imageId === image.id && previewState.status === "failed";
if (!previewReady && !previewFailed) return;
setOriginalGate((current) =>
current.imageId === image.id ? { imageId: image.id, open: true } : current,
);
}, [image.id, previewReady, previewState.status]);
const handleOriginalError = useCallback((event: SyntheticEvent<HTMLImageElement>) => {
const element = event.currentTarget;
const imageId = element.dataset.imageId;
const attempt = Number(element.dataset.sourceAttempt ?? -1);
if (
imageId &&
imageId === currentImageIdRef.current &&
attempt === sourceAttemptRef.current &&
element.isConnected
) {
setSourceState({ imageId, status: "failed" });
}
}, []);
useEffect(() => {
if (!previewReady || !next?.previewUrl) return;
let cancelled = false;
const prefetchTimer = window.setTimeout(() => {
if (cancelled) return;
const preloader = new Image();
preloader.decoding = "async";
preloader.fetchPriority = "low";
preloader.src = next.previewUrl;
}, 280);
return () => {
cancelled = true;
window.clearTimeout(prefetchTimer);
};
}, [image.id, next?.previewUrl, previewReady]);
const retryOriginal = useCallback(() => {
setSourceState({ imageId: image.id, status: "loading" });
setRetryState((current) => ({
imageId: image.id,
attempt: current.imageId === image.id ? current.attempt + 1 : 1,
}));
revealControls();
}, [image.id, revealControls]);
const toggleFullscreen = useCallback(async () => {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await rootRef.current?.requestFullscreen();
} catch {
// Browsers may deny fullscreen in embedded contexts.
}
}, []);
useEffect(() => {
const update = () => setFullscreen(document.fullscreenElement === rootRef.current);
document.addEventListener("fullscreenchange", update);
return () => document.removeEventListener("fullscreenchange", update);
}, []);
useEffect(() => {
const previousFocus = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const app = document.querySelector<HTMLElement>(".app-shell");
const previousInert = app?.inert ?? false;
const previousOverflow = document.body.style.overflow;
const previousPadding = document.body.style.paddingRight;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
if (app) app.inert = true;
document.body.style.overflow = "hidden";
if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`;
const frame = window.requestAnimationFrame(() => rootRef.current?.focus());
scheduleControlsHide();
return () => {
window.cancelAnimationFrame(frame);
if (app) app.inert = previousInert;
document.body.style.overflow = previousOverflow;
document.body.style.paddingRight = previousPadding;
previousFocus?.focus({ preventScroll: true });
};
}, [scheduleControlsHide]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") {
const controlsHidden = rootRef.current?.dataset.controlsVisible === "false";
const focusable = [...(rootRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])',
) ?? [])].filter((element) =>
element.offsetParent !== null &&
(!controlsHidden || element.classList.contains("viewer-essential"))
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable.at(-1)!;
if (document.activeElement === rootRef.current) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
return;
}
if (event.altKey || event.ctrlKey || event.metaKey) return;
switch (event.key) {
case "Escape":
event.preventDefault();
requestClose();
break;
case "ArrowLeft":
event.preventDefault();
engine.navigate(-1);
break;
case "ArrowRight":
event.preventDefault();
engine.navigate(1);
break;
case "+":
case "=":
event.preventDefault();
engine.zoomBy(1.4);
break;
case "-":
event.preventDefault();
engine.zoomBy(1 / 1.4);
break;
case "0":
event.preventDefault();
engine.resetZoom();
break;
case "f":
case "F":
event.preventDefault();
void toggleFullscreen();
break;
case " ":
if ((event.target as HTMLElement | null)?.closest("button, a, input, select, textarea")) {
return;
}
event.preventDefault();
commitSingleTap();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [commitSingleTap, engine, requestClose, toggleFullscreen]);
useEffect(() => () => {
window.clearTimeout(closeTimerRef.current);
window.clearTimeout(controlsTimerRef.current);
window.clearTimeout(tapTimerRef.current);
window.clearTimeout(pendingTimerRef.current);
window.clearTimeout(pendingRetryRef.current);
}, []);
const originalUrl = sourceAttempt
? `${image.originalUrl}?retry=${sourceAttempt}`
: image.originalUrl;
const frameStyle = {
width: frameSize.width,
height: frameSize.height,
} satisfies CSSProperties;
return createPortal(
<div
ref={rootRef}
className={`image-viewer ${closing ? "is-closing" : ""}`}
data-controls-visible={controlsVisible}
data-interacting={engine.interacting}
role="dialog"
aria-modal="true"
aria-label="图片查看器"
tabIndex={-1}
onFocusCapture={revealControls}
onPointerMove={(event) => {
if (event.pointerType === "mouse" && event.buttons === 0) revealControls();
}}
>
<div
ref={engine.canvasRef}
className="viewer-canvas"
aria-label={`${image.name},第 ${index + 1} 张,共 ${total} 张`}
{...canvasHandlers}
>
<div ref={engine.trackRef} className="viewer-track">
{previous && (
<NeighborSlide
image={previous}
viewport={viewport}
offset={-slideSpan}
/>
)}
<div className="viewer-slide viewer-slide-current">
<div className="viewer-media" style={frameStyle}>
<div
ref={engine.motionRef}
className="viewer-transform"
data-zoomed="false"
onContextMenu={(event) => event.preventDefault()}
>
<div className="viewer-image-frame">
<img
className="viewer-image viewer-image-thumbnail"
src={image.thumbnailUrl}
alt=""
data-image-id={image.id}
draggable={false}
decoding="async"
loading="eager"
fetchPriority="high"
onLoad={handleThumbnailLoad}
/>
<img
key={`${image.id}-preview`}
className={`viewer-image viewer-image-preview ${previewReady && !originalReady ? "is-ready" : ""}`}
src={image.previewUrl}
alt=""
data-image-id={image.id}
draggable={false}
decoding="async"
fetchPriority="high"
onLoad={handlePreviewLoad}
onError={handlePreviewError}
/>
{originalEnabled && (
<img
key={`${image.id}-${sourceAttempt}`}
className={`viewer-image viewer-image-original ${originalReady ? "is-ready" : ""}`}
src={originalUrl}
alt={image.name}
data-image-id={image.id}
data-source-attempt={sourceAttempt}
draggable={false}
decoding="async"
loading="eager"
fetchPriority="auto"
onLoad={handleOriginalLoad}
onError={handleOriginalError}
/>
)}
{!thumbnailReady && !previewReady && !originalReady && !originalFailed && (
<span className="viewer-loading" aria-label="正在载入原图">
<LoaderCircle className="spin" size={22} />
</span>
)}
</div>
</div>
</div>
</div>
{next && (
<NeighborSlide
image={next}
viewport={viewport}
offset={slideSpan}
/>
)}
</div>
</div>
<header
className="viewer-topbar viewer-chrome"
onPointerEnter={() => window.clearTimeout(controlsTimerRef.current)}
onPointerLeave={scheduleControlsHide}
onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)}
onBlurCapture={scheduleControlsHide}
>
<div className="viewer-about">
<span className="viewer-counter">
{formatCount(index + 1)} <i>/</i> {formatCount(total)}
</span>
<span className="viewer-file-copy">
<strong title={image.name}>{image.name}</strong>
<small>
{formatCount(image.width)} x {formatCount(image.height)} · {formatBytes(image.size)}
</small>
</span>
</div>
</header>
<ViewerButton
label="关闭查看器"
className="viewer-close viewer-essential"
onClick={requestClose}
>
<X size={22} />
</ViewerButton>
{!compact && (
<>
<ViewerButton
label="上一张"
className="viewer-nav viewer-nav-previous viewer-chrome"
onClick={() => engine.navigate(-1)}
disabled={!previous}
>
<ChevronLeft size={28} />
</ViewerButton>
<ViewerButton
label={pendingNext ? "正在载入下一张" : "下一张"}
className="viewer-nav viewer-nav-next viewer-chrome"
onClick={() => engine.navigate(1)}
disabled={index + 1 >= total}
>
{pendingNext ? <LoaderCircle className="spin" size={21} /> : <ChevronRight size={28} />}
</ViewerButton>
</>
)}
<div
className="viewer-dock viewer-chrome"
onPointerEnter={() => window.clearTimeout(controlsTimerRef.current)}
onPointerLeave={scheduleControlsHide}
onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)}
onBlurCapture={scheduleControlsHide}
>
{!compact && (
<>
<ViewerButton label="缩小" onClick={() => engine.zoomBy(1 / 1.4)} disabled={!engine.canZoom}>
<Minus size={19} />
</ViewerButton>
<output className="viewer-zoom-value" aria-live="off">
{engine.settledScale <= 1.01 ? "适应" : `${Math.round(engine.settledScale * 100)}%`}
</output>
<ViewerButton label="放大" onClick={() => engine.zoomBy(1.4)} disabled={!engine.canZoom}>
<Plus size={19} />
</ViewerButton>
</>
)}
<ViewerButton label="适应窗口" onClick={engine.resetZoom} disabled={engine.settledScale <= 1.01}>
<Scan size={19} />
</ViewerButton>
<span className="viewer-dock-divider" aria-hidden="true" />
<a
className="viewer-icon-button"
href={image.originalUrl}
download={image.name}
aria-label="下载原图"
title="下载原图"
>
<Download size={19} />
</a>
{!compact && (
<ViewerButton
label={fullscreen ? "退出全屏" : "进入全屏"}
onClick={() => void toggleFullscreen()}
>
{fullscreen ? <Minimize size={19} /> : <Maximize size={19} />}
</ViewerButton>
)}
</div>
{originalFailed && (
<div className="viewer-source-error viewer-chrome" role="status">
<ImageOff size={17} />
<span>原图载入失败</span>
<button type="button" onClick={retryOriginal}>
<RefreshCw size={15} />
重试
</button>
</div>
)}
<p className="sr-only" aria-live="polite">{announcement}</p>
</div>,
document.body,
);
}
function NeighborSlide({
image,
viewport,
offset,
}: {
image: GalleryImage;
viewport: ViewportSize;
offset: number;
}) {
const size = fittedSize(image, viewport);
return (
<div
className="viewer-slide viewer-slide-neighbor"
style={{ transform: `translate3d(${offset}px, 0, 0)` }}
aria-hidden="true"
>
<div className="viewer-neighbor-frame" style={{ width: size.width, height: size.height }}>
<img
src={image.thumbnailUrl}
alt=""
draggable={false}
decoding="async"
loading="lazy"
fetchPriority="low"
/>
</div>
</div>
);
}
function ViewerButton({
label,
onClick,
className = "",
disabled = false,
children,
}: {
label: string;
onClick: () => void;
className?: string;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
className={`viewer-icon-button ${className}`}
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
{children}
</button>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type {
GallerySummary,
ImagesPage,
SortMode,
ThumbnailStatus,
} from "./types";
async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`请求失败 (${response.status})`);
}
return (await response.json()) as T;
}
export function getGallery(signal?: AbortSignal): Promise<GallerySummary> {
return getJson<GallerySummary>("/api/gallery", signal);
}
export function getStatus(signal?: AbortSignal): Promise<ThumbnailStatus> {
return getJson<ThumbnailStatus>("/api/status", signal);
}
export function getImages(
options: {
album: string;
search: string;
sort: SortMode;
offset: number;
limit: number;
},
signal?: AbortSignal,
): Promise<ImagesPage> {
const params = new URLSearchParams({
offset: String(options.offset),
limit: String(options.limit),
sort: options.sort,
});
if (options.album) params.set("album", options.album);
if (options.search) params.set("search", options.search);
return getJson<ImagesPage>(`/api/images?${params}`, signal);
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
export interface Album {
path: string;
name: string;
count: number;
}
export interface GallerySummary {
total: number;
albums: Album[];
}
export interface GalleryImage {
id: string;
name: string;
album: string;
relativePath: string;
width: number;
height: number;
size: number;
modifiedMs: number;
thumbnailUrl: string;
previewUrl: string;
originalUrl: string;
}
export interface ImagesPage {
items: GalleryImage[];
total: number;
offset: number;
limit: number;
nextOffset: number | null;
}
export interface ThumbnailStatus {
total: number;
ready: number;
queued: number;
processing: number;
failed: number;
initialBatchReady: boolean;
backgroundComplete: boolean;
}
export type SortMode = "name-asc" | "name-desc" | "newest";
+826
View File
@@ -0,0 +1,826 @@
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
export interface Point {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
interface Motion extends Point {
scale: number;
}
interface CanvasGeometry extends Point, Size {}
type VisualUpdate =
| { kind: "motion"; motion: Motion }
| { kind: "track"; motion: Motion };
type GestureMode = "idle" | "pending" | "navigation" | "dismiss" | "pan" | "pinch";
interface Gesture {
mode: GestureMode;
pointerId: number;
pointerType: string;
start: Point;
last: Point;
lastTime: number;
velocity: Point;
origin: Motion;
pinchDistance: number;
pinchMidpoint: Point;
canvasCenter: Point;
moved: boolean;
}
interface EngineOptions {
imageId: string;
frameSize: Size;
maximumZoom: number;
canPrevious: boolean;
canNext: boolean;
onNavigate: (direction: -1 | 1) => void;
onEdge: (direction: -1 | 1) => void;
onDismiss: () => void;
onTap: (point: Point, pointerType: string) => void;
onDragStart: () => void;
onInteraction: () => void;
}
interface AnimateOptions {
duration?: number;
commitRaster?: boolean;
onComplete?: () => void;
}
const EMPTY_GESTURE: Gesture = {
mode: "idle",
pointerId: -1,
pointerType: "mouse",
start: { x: 0, y: 0 },
last: { x: 0, y: 0 },
lastTime: 0,
velocity: { x: 0, y: 0 },
origin: { x: 0, y: 0, scale: 1 },
pinchDistance: 0,
pinchMidpoint: { x: 0, y: 0 },
canvasCenter: { x: 0, y: 0 },
moved: false,
};
const RASTER_PIXEL_BUDGET = 40_000_000;
const RASTER_EDGE_LIMIT = 8192;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
function magnitude(point: Point): number {
return Math.hypot(point.x, point.y);
}
function distance(first: Point, second: Point): number {
return Math.hypot(first.x - second.x, first.y - second.y);
}
function midpoint(first: Point, second: Point): Point {
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
}
function resist(value: number, limit: number, factor = 0.2): number {
if (value > limit) return limit + (value - limit) * factor;
if (value < -limit) return -limit + (value + limit) * factor;
return value;
}
function easeOutQuart(value: number): number {
return 1 - (1 - value) ** 4;
}
function reducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function useViewerEngine(options: EngineOptions) {
const optionsRef = useRef(options);
optionsRef.current = options;
const canvasRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const motionRef = useRef<HTMLDivElement>(null);
const animationRef = useRef(0);
const trackAnimationRef = useRef(0);
const inputFrameRef = useRef(0);
const navigatingRef = useRef(false);
const navigationUnlockRef = useRef(0);
const rasterTimerRef = useRef(0);
const pointersRef = useRef(new Map<number, Point>());
const gestureRef = useRef<Gesture>({ ...EMPTY_GESTURE });
const layoutImageRef = useRef(options.imageId);
const currentRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
const targetRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
const trackMotionRef = useRef<Motion>({ x: 0, y: 0, scale: 1 });
const queuedVisualRef = useRef<VisualUpdate | null>(null);
const geometryRef = useRef<CanvasGeometry>({
x: 0,
y: 0,
width: window.innerWidth,
height: window.innerHeight,
});
const rasterScaleRef = useRef(1);
const [settledScale, setSettledScale] = useState(1);
const [interacting, setInteracting] = useState(false);
const captureGeometry = useCallback(() => {
const rect = canvasRef.current?.getBoundingClientRect();
geometryRef.current = {
x: rect?.left ?? 0,
y: rect?.top ?? 0,
width: rect?.width ?? window.innerWidth,
height: rect?.height ?? window.innerHeight,
};
}, []);
const pageSpan = useCallback(() => {
const width = geometryRef.current.width;
return width + (width <= 720 ? 12 : 28);
}, []);
const boundsFor = useCallback((scale: number) => {
const { frameSize } = optionsRef.current;
const { width, height } = geometryRef.current;
return {
x: Math.max(0, (frameSize.width * scale - width) / 2),
y: Math.max(0, (frameSize.height * scale - height) / 2),
};
}, []);
const constrain = useCallback((motion: Motion): Motion => {
const maximumZoom = Math.max(1, optionsRef.current.maximumZoom);
const scale = clamp(motion.scale, 1, maximumZoom);
const bounds = boundsFor(scale);
return {
x: clamp(motion.x, -bounds.x, bounds.x),
y: clamp(motion.y, -bounds.y, bounds.y),
scale,
};
}, [boundsFor]);
const applyMotion = useCallback((motion: Motion) => {
const element = motionRef.current;
if (!element) return;
const rasterScale = rasterScaleRef.current;
element.style.transform = `translate3d(${motion.x.toFixed(2)}px, ${motion.y.toFixed(2)}px, 0) scale(${(motion.scale / rasterScale).toFixed(4)})`;
element.dataset.zoomed = String(motion.scale > 1.01);
}, []);
const commitRaster = useCallback((
motion = currentRef.current,
settle = true,
displayedMotion = motion,
) => {
const element = motionRef.current;
if (!element) return;
const { frameSize } = optionsRef.current;
const density = clamp(window.devicePixelRatio || 1, 1, 3);
const areaScale = Math.sqrt(
RASTER_PIXEL_BUDGET /
Math.max(frameSize.width * frameSize.height * density * density, 1),
);
const edgeScale = Math.min(
RASTER_EDGE_LIMIT / Math.max(frameSize.width * density, 1),
RASTER_EDGE_LIMIT / Math.max(frameSize.height * density, 1),
);
const maximumRasterScale = Math.max(1, Math.min(areaScale, edgeScale));
const requestedRasterScale = motion.scale <= 1.01
? 1
: Math.max(1, Math.min(motion.scale, maximumRasterScale));
// Keep an already-promoted backing layer while zoomed. Dropping it on
// every zoom-out makes the browser resample a smaller bitmap and can
// soften the next quick pinch. The fit state still releases it.
const rasterScale = motion.scale <= 1.01
? 1
: Math.max(
requestedRasterScale,
Math.min(rasterScaleRef.current, maximumRasterScale),
);
rasterScaleRef.current = rasterScale;
const snap = (value: number) => Math.round(value * density) / density;
const width = snap(frameSize.width * rasterScale);
const height = snap(frameSize.height * rasterScale);
const left = snap((frameSize.width - width) / 2);
const top = snap((frameSize.height - height) / 2);
element.style.width = `${width.toFixed(2)}px`;
element.style.height = `${height.toFixed(2)}px`;
element.style.left = `${left.toFixed(2)}px`;
element.style.top = `${top.toFixed(2)}px`;
element.style.setProperty("--viewer-will-change", settle ? "auto" : "transform");
element.dataset.scale = motion.scale.toFixed(4);
element.dataset.rasterScale = rasterScale.toFixed(4);
// A preheat can happen before an animation reaches its destination. Keep
// the currently displayed transform while changing the backing layer so
// promotion itself never causes a visible jump.
applyMotion(displayedMotion);
if (settle) setSettledScale(motion.scale);
}, [applyMotion]);
// Keep a little headroom in the backing layer while the user is zooming.
// Rebuilding on every pointer event is expensive, so only promote it after
// a meaningful scale increase. Zooming out can keep the larger layer and
// therefore remains sharp without another allocation.
const prepareRaster = useCallback((motion: Motion) => {
const scale = Math.max(1, motion.scale);
const currentRaster = rasterScaleRef.current;
if (scale <= currentRaster * 1.25) return;
commitRaster(constrain(motion), false, currentRef.current);
}, [commitRaster, constrain]);
const scheduleRaster = useCallback((delay = 100) => {
window.clearTimeout(rasterTimerRef.current);
rasterTimerRef.current = window.setTimeout(() => commitRaster(), delay);
}, [commitRaster]);
const stopImageAnimation = useCallback(() => {
window.cancelAnimationFrame(animationRef.current);
animationRef.current = 0;
motionRef.current?.style.setProperty("--viewer-will-change", "auto");
}, []);
const setMotion = useCallback((motion: Motion) => {
currentRef.current = motion;
targetRef.current = motion;
applyMotion(motion);
}, [applyMotion]);
const animateMotion = useCallback((destination: Motion, animation: AnimateOptions = {}) => {
stopImageAnimation();
window.clearTimeout(rasterTimerRef.current);
const target = constrain(destination);
prepareRaster(target);
targetRef.current = target;
const duration = reducedMotion() ? 0 : (animation.duration ?? 240);
if (!duration) {
setMotion(target);
if (animation.commitRaster !== false) commitRaster(target);
animation.onComplete?.();
return;
}
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
const start = currentRef.current;
const startedAt = performance.now();
const tick = (time: number) => {
const progress = clamp((time - startedAt) / duration, 0, 1);
const eased = easeOutQuart(progress);
const next = {
x: start.x + (target.x - start.x) * eased,
y: start.y + (target.y - start.y) * eased,
scale: start.scale + (target.scale - start.scale) * eased,
};
currentRef.current = next;
applyMotion(next);
if (progress < 1) {
animationRef.current = window.requestAnimationFrame(tick);
return;
}
animationRef.current = 0;
currentRef.current = target;
applyMotion(target);
if (animation.commitRaster !== false) commitRaster(target);
animation.onComplete?.();
};
animationRef.current = window.requestAnimationFrame(tick);
}, [applyMotion, commitRaster, constrain, prepareRaster, setMotion, stopImageAnimation]);
const applyTrack = useCallback((x: number, y = 0, scale = 1) => {
const canvas = canvasRef.current;
const progress = clamp(
Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1),
0,
1,
);
trackMotionRef.current = { x, y, scale };
const track = trackRef.current;
if (track) {
track.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) scale(${scale.toFixed(4)})`;
}
if (canvas) {
canvas.style.setProperty("--viewer-dismiss-progress", progress.toFixed(4));
}
}, []);
const flushVisual = useCallback(() => {
window.cancelAnimationFrame(inputFrameRef.current);
inputFrameRef.current = 0;
const update = queuedVisualRef.current;
queuedVisualRef.current = null;
if (!update) return;
if (update.kind === "motion") applyMotion(update.motion);
else applyTrack(update.motion.x, update.motion.y, update.motion.scale);
}, [applyMotion, applyTrack]);
const queueVisual = useCallback((update: VisualUpdate) => {
queuedVisualRef.current = update;
if (inputFrameRef.current) return;
inputFrameRef.current = window.requestAnimationFrame(() => {
inputFrameRef.current = 0;
const pending = queuedVisualRef.current;
queuedVisualRef.current = null;
if (!pending) return;
if (pending.kind === "motion") applyMotion(pending.motion);
else applyTrack(pending.motion.x, pending.motion.y, pending.motion.scale);
});
}, [applyMotion, applyTrack]);
const stopTrackAnimation = useCallback(() => {
window.cancelAnimationFrame(trackAnimationRef.current);
trackAnimationRef.current = 0;
trackRef.current?.style.setProperty("will-change", "auto");
}, []);
const animateTrack = useCallback((
from: Point & { scale: number },
to: Point & { scale: number },
duration: number,
onComplete?: () => void,
) => {
stopTrackAnimation();
trackRef.current?.style.setProperty("will-change", "transform");
const actualDuration = reducedMotion() ? 0 : duration;
if (!actualDuration) {
applyTrack(to.x, to.y, to.scale);
trackRef.current?.style.setProperty("will-change", "auto");
onComplete?.();
return;
}
const startedAt = performance.now();
const tick = (time: number) => {
const progress = clamp((time - startedAt) / actualDuration, 0, 1);
const eased = easeOutQuart(progress);
applyTrack(
from.x + (to.x - from.x) * eased,
from.y + (to.y - from.y) * eased,
from.scale + (to.scale - from.scale) * eased,
);
if (progress < 1) {
trackAnimationRef.current = window.requestAnimationFrame(tick);
return;
}
trackAnimationRef.current = 0;
trackRef.current?.style.setProperty("will-change", "auto");
onComplete?.();
};
trackAnimationRef.current = window.requestAnimationFrame(tick);
}, [applyTrack, stopTrackAnimation]);
const zoomAround = useCallback((base: Motion, scale: number, point: Point): Motion => {
const geometry = geometryRef.current;
const center = {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
};
const ratio = scale / Math.max(base.scale, 0.001);
return constrain({
x: point.x - center.x - (point.x - center.x - base.x) * ratio,
y: point.y - center.y - (point.y - center.y - base.y) * ratio,
scale,
});
}, [constrain]);
const resetZoom = useCallback(() => {
optionsRef.current.onInteraction();
animateMotion({ x: 0, y: 0, scale: 1 }, { duration: 180 });
}, [animateMotion]);
const zoomTo = useCallback((scale: number, point?: Point) => {
const maximum = Math.max(1, optionsRef.current.maximumZoom);
const nextScale = clamp(scale, 1, maximum);
const geometry = geometryRef.current;
const anchor = point ?? {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
};
optionsRef.current.onInteraction();
animateMotion(zoomAround(currentRef.current, nextScale, anchor), { duration: 180 });
}, [animateMotion, zoomAround]);
const zoomBy = useCallback((factor: number) => {
zoomTo(currentRef.current.scale * factor);
}, [zoomTo]);
const toggleZoomAt = useCallback((point: Point) => {
if (currentRef.current.scale > 1.08) {
resetZoom();
return;
}
zoomTo(Math.min(4, Math.max(1, optionsRef.current.maximumZoom)), point);
}, [resetZoom, zoomTo]);
const completeNavigation = useCallback((direction: -1 | 1) => {
const sourceId = optionsRef.current.imageId;
optionsRef.current.onNavigate(direction);
window.clearTimeout(navigationUnlockRef.current);
navigationUnlockRef.current = window.setTimeout(() => {
navigatingRef.current = false;
if (optionsRef.current.imageId === sourceId) applyTrack(0, 0, 1);
}, 500);
}, [applyTrack]);
const navigate = useCallback((direction: -1 | 1) => {
if (navigatingRef.current) return false;
const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext;
if (!available) {
optionsRef.current.onEdge(direction);
return false;
}
optionsRef.current.onDragStart();
flushVisual();
navigatingRef.current = true;
const slide = () => {
const destination = -direction * pageSpan();
animateTrack(
trackMotionRef.current,
{ x: destination, y: 0, scale: 1 },
260,
() => completeNavigation(direction),
);
};
if (currentRef.current.scale > 1.01) {
animateMotion(
{ x: 0, y: 0, scale: 1 },
{ duration: 160, onComplete: slide },
);
} else {
stopImageAnimation();
slide();
}
return true;
}, [animateMotion, animateTrack, completeNavigation, flushVisual, pageSpan, stopImageAnimation]);
const beginPinch = useCallback(() => {
const pointers = [...pointersRef.current.values()];
if (pointers.length < 2) return;
const first = pointers[0];
const second = pointers[1];
const geometry = geometryRef.current;
flushVisual();
applyTrack(0, 0, 1);
optionsRef.current.onDragStart();
gestureRef.current = {
...EMPTY_GESTURE,
mode: "pinch",
origin: currentRef.current,
pinchDistance: Math.max(1, distance(first, second)),
pinchMidpoint: midpoint(first, second),
canvasCenter: {
x: geometry.x + geometry.width / 2,
y: geometry.y + geometry.height / 2,
},
moved: true,
};
setInteracting(true);
}, [applyTrack, flushVisual]);
const handlePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
if (navigatingRef.current) return;
event.preventDefault();
captureGeometry();
flushVisual();
stopImageAnimation();
stopTrackAnimation();
window.clearTimeout(rasterTimerRef.current);
targetRef.current = currentRef.current;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Synthetic events and older WebViews may not expose pointer capture.
}
const point = { x: event.clientX, y: event.clientY };
pointersRef.current.set(event.pointerId, { ...point });
optionsRef.current.onInteraction();
if (pointersRef.current.size >= 2) {
beginPinch();
return;
}
gestureRef.current = {
...EMPTY_GESTURE,
mode: "pending",
pointerId: event.pointerId,
pointerType: event.pointerType,
start: { ...point },
last: { ...point },
lastTime: performance.now(),
origin: currentRef.current,
};
}, [beginPinch, captureGeometry, flushVisual, stopImageAnimation, stopTrackAnimation]);
const handlePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
const tracked = pointersRef.current.get(event.pointerId);
if (!tracked) return;
event.preventDefault();
pointersRef.current.set(event.pointerId, {
x: event.clientX,
y: event.clientY,
});
const gesture = gestureRef.current;
if (gesture.mode === "pinch") {
const pointers = [...pointersRef.current.values()];
if (pointers.length < 2) return;
const first = pointers[0];
const second = pointers[1];
const midpointNow = midpoint(first, second);
const rawScale = gesture.origin.scale *
(distance(first, second) / Math.max(gesture.pinchDistance, 1));
const maximum = Math.max(1, optionsRef.current.maximumZoom);
const scale = rawScale < 1
? 1 - (1 - rawScale) * 0.22
: rawScale > maximum
? maximum + (rawScale - maximum) * 0.16
: rawScale;
const center = gesture.canvasCenter;
const local = {
x: (gesture.pinchMidpoint.x - center.x - gesture.origin.x) / gesture.origin.scale,
y: (gesture.pinchMidpoint.y - center.y - gesture.origin.y) / gesture.origin.scale,
};
const next = {
x: midpointNow.x - center.x - local.x * scale,
y: midpointNow.y - center.y - local.y * scale,
scale,
};
currentRef.current = next;
targetRef.current = next;
prepareRaster(next);
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
queueVisual({ kind: "motion", motion: next });
return;
}
if (gesture.pointerId !== event.pointerId || gesture.mode === "idle") return;
const point = { x: event.clientX, y: event.clientY };
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
const now = performance.now();
const elapsed = Math.max(1, now - gesture.lastTime);
gesture.velocity = {
x: gesture.velocity.x * 0.62 + ((point.x - gesture.last.x) / elapsed) * 0.38,
y: gesture.velocity.y * 0.62 + ((point.y - gesture.last.y) / elapsed) * 0.38,
};
gesture.last = point;
gesture.lastTime = now;
if (gesture.mode === "pending" && magnitude(delta) >= (gesture.pointerType === "mouse" ? 5 : 9)) {
gesture.moved = true;
optionsRef.current.onDragStart();
setInteracting(true);
if (gesture.origin.scale > 1.01) gesture.mode = "pan";
else if (Math.abs(delta.x) > Math.abs(delta.y) * 1.08) gesture.mode = "navigation";
else if (gesture.pointerType === "mouse") gesture.mode = "idle";
else gesture.mode = "dismiss";
}
if (gesture.mode === "navigation") {
let x = delta.x;
if ((x > 0 && !optionsRef.current.canPrevious) || (x < 0 && !optionsRef.current.canNext)) {
x *= 0.22;
}
queueVisual({ kind: "track", motion: { x, y: 0, scale: 1 } });
return;
}
if (gesture.mode === "dismiss") {
const y = delta.y >= 0 ? delta.y * 0.94 : delta.y * 0.16;
const progress = clamp(Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1), 0, 1);
queueVisual({
kind: "track",
motion: { x: delta.x * 0.08, y, scale: 1 - progress * 0.055 },
});
return;
}
if (gesture.mode === "pan") {
const scale = gesture.origin.scale;
const bounds = boundsFor(scale);
const next = {
x: resist(gesture.origin.x + delta.x, bounds.x),
y: resist(gesture.origin.y + delta.y, bounds.y),
scale,
};
currentRef.current = next;
targetRef.current = next;
motionRef.current?.style.setProperty("--viewer-will-change", "transform");
queueVisual({ kind: "motion", motion: next });
}
}, [boundsFor, prepareRaster, queueVisual]);
const finishPointer = useCallback((event: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) => {
const gesture = gestureRef.current;
const tracked = pointersRef.current.has(event.pointerId);
pointersRef.current.delete(event.pointerId);
if (!tracked) return;
flushVisual();
if (gesture.mode === "pinch") {
const remaining = [...pointersRef.current.entries()];
if (remaining.length === 1) {
window.clearTimeout(rasterTimerRef.current);
const [pointerId, point] = remaining[0];
gestureRef.current = {
...EMPTY_GESTURE,
mode: "pan",
pointerId,
pointerType: "touch",
start: { ...point },
last: { ...point },
lastTime: performance.now(),
origin: currentRef.current,
moved: true,
};
return;
}
const target = constrain(currentRef.current);
animateMotion(target, { duration: 220 });
gestureRef.current = { ...EMPTY_GESTURE };
setInteracting(false);
return;
}
if (gesture.pointerId !== event.pointerId) return;
const endPoint = cancelled
? gesture.last
: { x: event.clientX, y: event.clientY };
const delta = {
x: endPoint.x - gesture.start.x,
y: endPoint.y - gesture.start.y,
};
const displayedTrack = trackMotionRef.current;
let keepInteracting = false;
if (cancelled && gesture.mode === "pan") {
animateMotion(constrain(currentRef.current), { duration: 180 });
} else if (cancelled) {
animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 180);
} else if (gesture.mode === "navigation") {
const width = geometryRef.current.width;
const direction: -1 | 1 = delta.x > 0 ? -1 : 1;
const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext;
const commit = available &&
(Math.abs(delta.x) > Math.min(120, width * 0.2) || Math.abs(gesture.velocity.x) > 0.55);
if (commit) {
navigatingRef.current = true;
const destination = -direction * pageSpan();
animateTrack(
displayedTrack,
{ x: destination, y: 0, scale: 1 },
230,
() => completeNavigation(direction),
);
} else {
if (!available) optionsRef.current.onEdge(direction);
animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 210);
}
} else if (gesture.mode === "dismiss") {
const height = geometryRef.current.height;
const commit = delta.y > Math.min(150, height * 0.18) ||
(delta.y > 50 && gesture.velocity.y > 0.65);
if (commit) {
navigatingRef.current = true;
keepInteracting = true;
animateTrack(
displayedTrack,
{ x: delta.x * 0.12, y: height * 1.08, scale: 0.92 },
240,
optionsRef.current.onDismiss,
);
} else {
animateTrack(
displayedTrack,
{ x: 0, y: 0, scale: 1 },
220,
);
}
} else if (gesture.mode === "pan") {
const inertial = {
x: currentRef.current.x + clamp(gesture.velocity.x * 130, -180, 180),
y: currentRef.current.y + clamp(gesture.velocity.y * 130, -180, 180),
scale: currentRef.current.scale,
};
animateMotion(constrain(inertial), { duration: 260 });
} else if (gesture.mode === "pending" && !gesture.moved) {
optionsRef.current.onTap(endPoint, gesture.pointerType);
}
gestureRef.current = { ...EMPTY_GESTURE };
if (!keepInteracting) setInteracting(false);
}, [animateMotion, animateTrack, completeNavigation, constrain, flushVisual, pageSpan]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
if (optionsRef.current.maximumZoom <= 1.01) return;
optionsRef.current.onInteraction();
stopImageAnimation();
const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE
? event.deltaY * 18
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? event.deltaY * geometryRef.current.height
: event.deltaY;
targetRef.current = currentRef.current;
const base = currentRef.current;
const scale = clamp(
base.scale * Math.exp(-delta * 0.00135),
1,
Math.max(1, optionsRef.current.maximumZoom),
);
const target = zoomAround(base, scale, { x: event.clientX, y: event.clientY });
const motionReduced = reducedMotion();
animateMotion(target, { duration: 90, commitRaster: motionReduced });
if (!motionReduced) scheduleRaster(120);
};
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => canvas.removeEventListener("wheel", handleWheel);
}, [animateMotion, scheduleRaster, stopImageAnimation, zoomAround]);
useEffect(() => {
const update = () => captureGeometry();
window.addEventListener("resize", update);
window.visualViewport?.addEventListener("resize", update);
return () => {
window.removeEventListener("resize", update);
window.visualViewport?.removeEventListener("resize", update);
};
}, [captureGeometry]);
useLayoutEffect(() => {
captureGeometry();
flushVisual();
stopImageAnimation();
stopTrackAnimation();
window.clearTimeout(rasterTimerRef.current);
window.clearTimeout(navigationUnlockRef.current);
pointersRef.current.clear();
gestureRef.current = { ...EMPTY_GESTURE };
if (layoutImageRef.current !== options.imageId) {
layoutImageRef.current = options.imageId;
currentRef.current = { x: 0, y: 0, scale: 1 };
targetRef.current = { x: 0, y: 0, scale: 1 };
rasterScaleRef.current = 1;
} else {
const motion = constrain(currentRef.current);
currentRef.current = motion;
targetRef.current = motion;
}
navigatingRef.current = false;
applyTrack(0, 0, 1);
commitRaster(currentRef.current);
setInteracting(false);
}, [options.imageId, options.frameSize.height, options.frameSize.width, applyTrack, captureGeometry, commitRaster, constrain, flushVisual, stopImageAnimation, stopTrackAnimation]);
useEffect(() => {
if (currentRef.current.scale <= options.maximumZoom + 0.001) return;
animateMotion(constrain(currentRef.current));
}, [options.maximumZoom, animateMotion, constrain]);
useEffect(() => () => {
window.cancelAnimationFrame(animationRef.current);
window.cancelAnimationFrame(trackAnimationRef.current);
window.cancelAnimationFrame(inputFrameRef.current);
window.clearTimeout(rasterTimerRef.current);
window.clearTimeout(navigationUnlockRef.current);
}, []);
return {
canvasRef,
trackRef,
motionRef,
settledScale,
interacting,
canZoom: options.maximumZoom > 1.01,
handlePointerDown,
handlePointerMove,
handlePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, false),
handlePointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, true),
handleLostPointerCapture: (event: ReactPointerEvent<HTMLDivElement>) => finishPointer(event, true),
navigate,
resetZoom,
toggleZoomAt,
zoomBy,
};
}
export type ViewerEngine = ReturnType<typeof useViewerEngine>;
export type ViewerElementRef = RefObject<HTMLDivElement | null>;
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
target: "es2022",
outDir: "dist",
emptyOutDir: true,
cssCodeSplit: false,
rollupOptions: {
output: {
entryFileNames: "assets/app.js",
chunkFileNames: "assets/[name].js",
assetFileNames: (asset) =>
asset.name?.endsWith(".css") ? "assets/app.css" : "assets/[name][extname]",
},
},
},
});
+125
View File
@@ -0,0 +1,125 @@
use std::{env, net::SocketAddr, path::PathBuf, time::Duration};
use anyhow::{Context, Result, bail};
#[derive(Clone, Debug)]
pub struct Config {
pub gallery_dir: PathBuf,
pub cache_dir: PathBuf,
pub listen: SocketAddr,
pub initial_batch: usize,
pub workers: usize,
pub scan_interval: Duration,
}
impl Config {
pub fn from_env_and_args() -> Result<Self> {
let cwd = env::current_dir().context("cannot determine the working directory")?;
let mut gallery_dir = cwd.join("pic");
let mut cache_dir = cwd.join(".pixhelf-cache").join("thumbnails");
let mut listen = "0.0.0.0:3002".parse::<SocketAddr>()?;
let mut initial_batch = 60usize;
let mut workers = default_worker_count();
let mut scan_interval = Duration::from_secs(10);
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--gallery-dir" => gallery_dir = path_value(&mut args, &arg, &cwd)?,
"--cache-dir" => cache_dir = path_value(&mut args, &arg, &cwd)?,
"--listen" => {
listen = value(&mut args, &arg)?
.parse()
.with_context(|| "--listen must be in HOST:PORT form")?;
}
"--initial-batch" => {
initial_batch = number_value(&mut args, &arg, 1, 500)?;
}
"--workers" => workers = number_value(&mut args, &arg, 1, 16)?,
"--scan-interval" => {
scan_interval =
Duration::from_secs(number_value(&mut args, &arg, 2, 3600)? as u64);
}
"-h" | "--help" => {
print_help();
std::process::exit(0);
}
unknown => bail!("unknown argument: {unknown}\n\nRun pixhelf --help for usage."),
}
}
if !gallery_dir.is_dir() {
bail!(
"gallery directory does not exist: {}",
gallery_dir.display()
);
}
std::fs::create_dir_all(&cache_dir)
.with_context(|| format!("cannot create cache directory: {}", cache_dir.display()))?;
Ok(Self {
gallery_dir,
cache_dir,
listen,
initial_batch,
workers,
scan_interval,
})
}
}
fn value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> {
args.next()
.with_context(|| format!("{flag} requires a value"))
}
fn path_value(
args: &mut impl Iterator<Item = String>,
flag: &str,
cwd: &std::path::Path,
) -> Result<PathBuf> {
let path = PathBuf::from(value(args, flag)?);
Ok(if path.is_absolute() {
path
} else {
cwd.join(path)
})
}
fn number_value(
args: &mut impl Iterator<Item = String>,
flag: &str,
min: usize,
max: usize,
) -> Result<usize> {
let raw = value(args, flag)?;
let parsed = raw
.parse::<usize>()
.with_context(|| format!("{flag} must be a number"))?;
if !(min..=max).contains(&parsed) {
bail!("{flag} must be between {min} and {max}");
}
Ok(parsed)
}
fn default_worker_count() -> usize {
std::thread::available_parallelism()
.map(|parallelism| parallelism.get().clamp(2, 4))
.unwrap_or(2)
}
fn print_help() {
println!(
"pixhelf - a self-hosted image gallery\n\n\
Usage: pixhelf [OPTIONS]\n\n\
Options:\n \
--gallery-dir PATH Gallery root (default: ./pic)\n \
--cache-dir PATH Thumbnail and viewer preview cache (default: ./.pixhelf-cache/thumbnails)\n \
--listen HOST:PORT Listen address (default: 0.0.0.0:3002)\n \
--initial-batch N Thumbnails prepared before serving (default: 60)\n \
--workers N Background thumbnail workers (default: 2-4, based on CPU)\n \
--scan-interval SEC Gallery rescan interval (default: 10)\n \
-h, --help Show this help"
);
}
+259
View File
@@ -0,0 +1,259 @@
use std::{
collections::{BTreeMap, HashMap},
fs,
path::{Path, PathBuf},
sync::Arc,
time::UNIX_EPOCH,
};
use anyhow::{Context, Result};
use image::{ImageDecoder, ImageReader, metadata::Orientation};
use serde::Serialize;
use tracing::warn;
use walkdir::WalkDir;
#[derive(Clone, Debug)]
pub struct ImageRecord {
pub id: String,
pub path: PathBuf,
pub relative_path: String,
pub name: String,
pub album: String,
pub width: u32,
pub height: u32,
pub size: u64,
pub modified_ms: u64,
pub(crate) modified_ns: u128,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Album {
pub path: String,
pub name: String,
pub count: usize,
}
#[derive(Clone, Default)]
pub struct GalleryIndex {
pub images: Vec<Arc<ImageRecord>>,
pub by_id: HashMap<String, Arc<ImageRecord>>,
pub albums: Vec<Album>,
}
impl GalleryIndex {
pub fn get(&self, id: &str) -> Option<Arc<ImageRecord>> {
self.by_id.get(id).cloned()
}
pub fn same_revision(&self, other: &Self) -> bool {
self.images.len() == other.images.len()
&& self
.images
.iter()
.zip(&other.images)
.all(|(left, right)| left.id == right.id)
}
}
pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<GalleryIndex> {
let previous_by_path: HashMap<&str, &Arc<ImageRecord>> = previous
.into_iter()
.flat_map(|index| index.images.iter())
.map(|record| (record.relative_path.as_str(), record))
.collect();
let mut images = Vec::new();
for entry in WalkDir::new(root)
.follow_links(false)
.sort_by_file_name()
.into_iter()
.filter_map(|entry| match entry {
Ok(entry) => Some(entry),
Err(error) => {
warn!(%error, "cannot inspect gallery entry");
None
}
})
{
if !entry.file_type().is_file() || !is_supported_image(entry.path()) {
continue;
}
let path = entry.into_path();
let relative = path
.strip_prefix(root)
.with_context(|| format!("{} is outside the gallery root", path.display()))?;
let relative_path = path_to_url(relative);
let metadata = match fs::metadata(&path) {
Ok(metadata) => metadata,
Err(error) => {
warn!(path = %path.display(), %error, "cannot read image metadata");
continue;
}
};
let modified = metadata
.modified()
.unwrap_or(UNIX_EPOCH)
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let modified_ns = modified.as_nanos();
let size = metadata.len();
if let Some(existing) = previous_by_path.get(relative_path.as_str())
&& existing.size == size
&& existing.modified_ns == modified_ns
{
images.push(Arc::clone(existing));
continue;
}
let (width, height) = match probe_dimensions(&path) {
Ok(dimensions) => dimensions,
Err(error) => {
warn!(path = %path.display(), %error, "cannot decode image header");
continue;
}
};
let album = relative
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(path_to_url)
.unwrap_or_default();
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&relative_path)
.to_owned();
let id = image_id(&relative_path, size, modified_ns);
images.push(Arc::new(ImageRecord {
id,
path,
relative_path,
name,
album,
width,
height,
size,
modified_ms: modified.as_millis().min(u64::MAX as u128) as u64,
modified_ns,
}));
}
images.sort_by(|left, right| {
natord::compare_ignore_case(&left.relative_path, &right.relative_path)
});
let mut album_counts = BTreeMap::<String, usize>::new();
for image in &images {
*album_counts.entry(image.album.clone()).or_default() += 1;
}
let albums = album_counts
.into_iter()
.map(|(path, count)| Album {
name: path
.rsplit('/')
.next()
.filter(|name| !name.is_empty())
.unwrap_or("根目录")
.to_owned(),
path,
count,
})
.collect();
let by_id = images
.iter()
.map(|record| (record.id.clone(), Arc::clone(record)))
.collect();
Ok(GalleryIndex {
images,
by_id,
albums,
})
}
fn probe_dimensions(path: &Path) -> Result<(u32, u32)> {
let reader = ImageReader::open(path)?.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
let (width, height) = decoder.dimensions();
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
Ok(oriented_dimensions(width, height, orientation))
}
fn oriented_dimensions(width: u32, height: u32, orientation: Orientation) -> (u32, u32) {
match orientation {
Orientation::Rotate90
| Orientation::Rotate270
| Orientation::Rotate90FlipH
| Orientation::Rotate270FlipH => (height, width),
_ => (width, height),
}
}
fn is_supported_image(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"jpg" | "jpeg" | "png" | "webp"
)
})
.unwrap_or(false)
}
fn image_id(relative_path: &str, size: u64, modified_ns: u128) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(b"pixhelf-image-v1\0");
hasher.update(relative_path.as_bytes());
hasher.update(&size.to_le_bytes());
hasher.update(&modified_ns.to_le_bytes());
hasher.finalize().to_hex().to_string()
}
fn path_to_url(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn swaps_dimensions_for_quarter_turns() {
assert_eq!(
oriented_dimensions(1200, 800, Orientation::Rotate90),
(800, 1200)
);
assert_eq!(
oriented_dimensions(1200, 800, Orientation::Rotate270FlipH),
(800, 1200)
);
assert_eq!(
oriented_dimensions(1200, 800, Orientation::Rotate180),
(1200, 800)
);
}
#[test]
fn scans_images_and_skips_other_files() {
let temp = tempfile::tempdir().unwrap();
let album = temp.path().join("album");
fs::create_dir(&album).unwrap();
image::RgbImage::new(20, 10)
.save(album.join("image.png"))
.unwrap();
fs::write(album.join("notes.tsv"), "ignored").unwrap();
let index = scan_gallery(temp.path(), None).unwrap();
assert_eq!(index.images.len(), 1);
assert_eq!(index.images[0].album, "album");
assert_eq!((index.images[0].width, index.images[0].height), (20, 10));
assert_eq!(index.albums[0].count, 1);
}
}
+133
View File
@@ -0,0 +1,133 @@
mod config;
mod gallery;
mod thumbs;
mod web;
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use config::Config;
use gallery::scan_gallery;
use thumbs::{ThumbnailManager, ViewerPreviewManager};
use tokio::{net::TcpListener, sync::RwLock};
use tracing::{error, info};
use tracing_subscriber::EnvFilter;
use web::AppState;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("pixhelf=info,tower_http=info")),
)
.compact()
.init();
let config = Config::from_env_and_args()?;
info!(gallery = %config.gallery_dir.display(), "scanning gallery");
let gallery_dir = config.gallery_dir.clone();
let index = tokio::task::spawn_blocking(move || scan_gallery(&gallery_dir, None))
.await
.context("gallery scan task stopped")??;
if index.images.is_empty() {
bail!(
"no supported images found in {}",
config.gallery_dir.display()
);
}
info!(
images = index.images.len(),
albums = index.albums.len(),
"gallery indexed"
);
let initial_ids: Vec<String> = index
.images
.iter()
.take(config.initial_batch)
.map(|record| record.id.clone())
.collect();
let index = Arc::new(RwLock::new(index));
let thumbnails = ThumbnailManager::new(config.cache_dir.clone())?;
let previews = ViewerPreviewManager::new(config.cache_dir.clone())?;
{
let current = index.read().await;
thumbnails.reconcile(&current.images);
previews.reconcile(&current.images);
}
thumbnails.start_workers(config.workers);
let initial_thumbnail_manager = Arc::clone(&thumbnails);
tokio::spawn(async move {
initial_thumbnail_manager
.prepare_initial(&initial_ids)
.await;
});
let state = AppState {
index: Arc::clone(&index),
thumbnails: Arc::clone(&thumbnails),
previews: Arc::clone(&previews),
};
let cleanup_manager = Arc::clone(&thumbnails);
tokio::spawn(async move {
cleanup_manager.cleanup_stale().await;
});
let preview_cleanup_manager = Arc::clone(&previews);
tokio::spawn(async move {
preview_cleanup_manager.cleanup_stale().await;
});
tokio::spawn(rescan_gallery(state.clone(), config.clone()));
let listener = TcpListener::bind(config.listen)
.await
.with_context(|| format!("cannot listen on {}", config.listen))?;
info!(listen = %config.listen, "Pixhelf is ready");
axum::serve(listener, web::router(state))
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn rescan_gallery(state: AppState, config: Config) {
loop {
tokio::time::sleep(config.scan_interval).await;
let previous = state.index.read().await.clone();
let root = config.gallery_dir.clone();
let previous_for_scan = previous.clone();
let scan =
tokio::task::spawn_blocking(move || scan_gallery(&root, Some(&previous_for_scan)))
.await;
let updated = match scan {
Ok(Ok(index)) => index,
Ok(Err(error)) => {
error!(%error, "gallery rescan failed");
continue;
}
Err(error) => {
error!(%error, "gallery rescan task stopped");
continue;
}
};
if previous.same_revision(&updated) {
continue;
}
let old_count = previous.images.len();
let new_count = updated.images.len();
state.thumbnails.reconcile(&updated.images);
state.previews.reconcile(&updated.images);
*state.index.write().await = updated;
state.thumbnails.cleanup_stale().await;
state.previews.cleanup_stale().await;
info!(old_count, new_count, "gallery changes indexed");
}
}
async fn shutdown_signal() {
if let Err(error) = tokio::signal::ctrl_c().await {
error!(%error, "cannot install shutdown handler");
}
}
+757
View File
@@ -0,0 +1,757 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
fs::{self, File},
io::Write,
path::{Path, PathBuf},
sync::{
Arc, Mutex, RwLock,
atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering},
},
time::Duration,
};
use anyhow::{Context, Result, anyhow, bail};
use image::{
DynamicImage, GenericImageView, ImageDecoder, ImageReader, imageops::FilterType,
metadata::Orientation,
};
use serde::Serialize;
use tokio::sync::{Notify, Semaphore};
use tracing::{info, warn};
use walkdir::WalkDir;
use crate::gallery::ImageRecord;
const THUMBNAIL_EDGE: u32 = 720;
const WEBP_QUALITY: f32 = 82.0;
const CACHE_VERSION: &str = "720-webp-q82-v1";
const THUMBNAIL_FILTER: FilterType = FilterType::Triangle;
const MAX_ATTEMPTS: u8 = 3;
const PENDING: u8 = 0;
const PROCESSING: u8 = 1;
const READY: u8 = 2;
const FAILED: u8 = 3;
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub struct ThumbnailManager {
cache_root: PathBuf,
entries: RwLock<HashMap<String, Arc<ThumbEntry>>>,
queue: JobQueue,
initial_ready: AtomicBool,
}
const PREVIEW_CACHE_VERSION: &str = "2560-webp-q88-v2";
const PREVIEW_EDGE: u32 = 2560;
const PREVIEW_QUALITY: f32 = 88.0;
const PREVIEW_FILTER: FilterType = FilterType::CatmullRom;
const PREVIEW_PENDING: u8 = 0;
const PREVIEW_PROCESSING: u8 = 1;
const PREVIEW_READY: u8 = 2;
const PREVIEW_FAILED: u8 = 3;
/// Viewer previews are intentionally independent from the eager thumbnail queue.
/// They are generated only when a user opens an image, then retained immutably.
pub struct ViewerPreviewManager {
cache_root: PathBuf,
entries: RwLock<HashMap<String, Arc<PreviewEntry>>>,
slots: Arc<Semaphore>,
}
struct PreviewEntry {
record: Arc<ImageRecord>,
cache_path: PathBuf,
state: AtomicU8,
last_error: Mutex<Option<String>>,
notify: Notify,
}
impl ViewerPreviewManager {
pub fn new(cache_dir: PathBuf) -> Result<Arc<Self>> {
let cache_root = cache_dir.join(PREVIEW_CACHE_VERSION);
fs::create_dir_all(&cache_root).with_context(|| {
format!(
"cannot create viewer preview cache: {}",
cache_root.display()
)
})?;
purge_temporary_files(&cache_root);
Ok(Arc::new(Self {
cache_root,
entries: RwLock::new(HashMap::new()),
slots: Arc::new(Semaphore::new(2)),
}))
}
pub fn reconcile(&self, records: &[Arc<ImageRecord>]) {
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
let mut entries = self.entries.write().expect("viewer preview entries lock");
entries.retain(|id, _| current_ids.contains(id.as_str()));
for record in records {
if entries.contains_key(&record.id) {
continue;
}
let cache_path = shard_path(&self.cache_root, &record.id);
let state = if valid_cache_file(&cache_path) {
PREVIEW_READY
} else {
PREVIEW_PENDING
};
entries.insert(
record.id.clone(),
Arc::new(PreviewEntry {
record: Arc::clone(record),
cache_path,
state: AtomicU8::new(state),
last_error: Mutex::new(None),
notify: Notify::new(),
}),
);
}
}
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
let entry = self
.entries
.read()
.expect("viewer preview entries lock")
.get(id)
.cloned()
.with_context(|| format!("unknown image: {id}"))?;
loop {
let notified = entry.notify.notified();
tokio::pin!(notified);
notified.as_mut().enable();
match entry.state.load(Ordering::Acquire) {
PREVIEW_READY if valid_cache_file(&entry.cache_path) => {
return Ok(entry.cache_path.clone());
}
PREVIEW_READY => {
let _ = entry.state.compare_exchange(
PREVIEW_READY,
PREVIEW_PENDING,
Ordering::AcqRel,
Ordering::Acquire,
);
}
PREVIEW_PENDING => {
if entry
.state
.compare_exchange(
PREVIEW_PENDING,
PREVIEW_PROCESSING,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
self.start_generation(Arc::clone(&entry));
}
}
PREVIEW_PROCESSING => notified.as_mut().await,
PREVIEW_FAILED => {
let message = entry
.last_error
.lock()
.expect("viewer preview error lock")
.clone()
.unwrap_or_else(|| "viewer preview generation failed".to_owned());
bail!(message);
}
_ => unreachable!(),
}
}
}
fn start_generation(&self, entry: Arc<PreviewEntry>) {
let slots = Arc::clone(&self.slots);
tokio::spawn(async move {
let record = Arc::clone(&entry.record);
let output = entry.cache_path.clone();
let result = async move {
let _permit = slots
.acquire_owned()
.await
.map_err(|_| anyhow!("viewer preview worker stopped"))?;
tokio::task::spawn_blocking(move || generate_preview(&record, &output))
.await
.map_err(|error| anyhow!("viewer preview worker stopped: {error}"))?
}
.await;
match result {
Ok(()) => {
entry.state.store(PREVIEW_READY, Ordering::Release);
entry.notify.notify_waiters();
}
Err(error) => {
*entry.last_error.lock().expect("viewer preview error lock") =
Some(error.to_string());
entry.state.store(PREVIEW_FAILED, Ordering::Release);
entry.notify.notify_waiters();
warn!(image = %entry.record.relative_path, %error, "viewer preview generation failed");
}
}
});
}
pub async fn cleanup_stale(&self) {
let valid_ids: HashSet<String> = self
.entries
.read()
.expect("viewer preview entries lock")
.keys()
.cloned()
.collect();
let root = self.cache_root.clone();
if let Err(error) =
tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await
{
warn!(%error, "viewer preview cache cleanup task failed");
}
}
}
struct ThumbEntry {
record: Arc<ImageRecord>,
cache_path: PathBuf,
state: AtomicU8,
urgent: AtomicBool,
attempts: AtomicU8,
last_error: Mutex<Option<String>>,
notify: Notify,
}
#[derive(Default)]
struct JobQueue {
state: Mutex<QueueState>,
notify: Notify,
}
#[derive(Default)]
struct QueueState {
urgent: VecDeque<String>,
background: VecDeque<String>,
queued: HashSet<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThumbnailStatus {
pub total: usize,
pub ready: usize,
pub queued: usize,
pub processing: usize,
pub failed: usize,
pub initial_batch_ready: bool,
pub background_complete: bool,
}
impl ThumbnailManager {
pub fn new(cache_dir: PathBuf) -> Result<Arc<Self>> {
let cache_root = cache_dir.join(CACHE_VERSION);
fs::create_dir_all(&cache_root)
.with_context(|| format!("cannot create thumbnail cache: {}", cache_root.display()))?;
purge_temporary_files(&cache_root);
Ok(Arc::new(Self {
cache_root,
entries: RwLock::new(HashMap::new()),
queue: JobQueue::default(),
initial_ready: AtomicBool::new(false),
}))
}
pub fn reconcile(&self, records: &[Arc<ImageRecord>]) {
let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect();
let mut missing = Vec::new();
{
let mut entries = self.entries.write().expect("thumbnail entries lock");
entries.retain(|id, _| current_ids.contains(id.as_str()));
for record in records {
if entries.contains_key(&record.id) {
continue;
}
let cache_path = self.cache_path(&record.id);
let state = if valid_cache_file(&cache_path) {
READY
} else {
missing.push(record.id.clone());
PENDING
};
entries.insert(
record.id.clone(),
Arc::new(ThumbEntry {
record: Arc::clone(record),
cache_path,
state: AtomicU8::new(state),
urgent: AtomicBool::new(false),
attempts: AtomicU8::new(0),
last_error: Mutex::new(None),
notify: Notify::new(),
}),
);
}
}
for id in missing {
self.queue.push_background(id);
}
}
pub fn start_workers(self: &Arc<Self>, count: usize) {
for worker in 0..count {
let manager = Arc::clone(self);
tokio::spawn(async move {
manager.worker_loop(worker).await;
});
}
}
pub async fn prepare_initial(&self, ids: &[String]) {
for id in ids {
self.promote(id);
}
let mut failures = 0usize;
for id in ids {
if let Err(error) = self.wait_for(id).await {
failures += 1;
warn!(%id, %error, "initial thumbnail failed");
}
}
self.initial_ready.store(true, Ordering::Release);
info!(
requested = ids.len(),
failures, "initial thumbnail batch is ready"
);
}
pub async fn ensure_ready(&self, id: &str) -> Result<PathBuf> {
self.promote(id);
self.wait_for(id).await
}
pub fn status(&self) -> ThumbnailStatus {
let entries = self.entries.read().expect("thumbnail entries lock");
let mut ready = 0;
let mut queued = 0;
let mut processing = 0;
let mut failed = 0;
for entry in entries.values() {
match entry.state.load(Ordering::Acquire) {
READY => ready += 1,
PROCESSING => processing += 1,
FAILED => failed += 1,
_ => queued += 1,
}
}
let total = entries.len();
ThumbnailStatus {
total,
ready,
queued,
processing,
failed,
initial_batch_ready: self.initial_ready.load(Ordering::Acquire),
background_complete: ready + failed == total && processing == 0,
}
}
pub async fn cleanup_stale(&self) {
let valid_ids: HashSet<String> = self
.entries
.read()
.expect("thumbnail entries lock")
.keys()
.cloned()
.collect();
let root = self.cache_root.clone();
if let Err(error) =
tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await
{
warn!(%error, "thumbnail cache cleanup task failed");
}
}
fn promote(&self, id: &str) {
let entry = self
.entries
.read()
.expect("thumbnail entries lock")
.get(id)
.cloned();
if let Some(entry) = entry
&& entry.state.load(Ordering::Acquire) == PENDING
{
entry.urgent.store(true, Ordering::Release);
self.queue.push_urgent(id.to_owned());
}
}
async fn wait_for(&self, id: &str) -> Result<PathBuf> {
let entry = self
.entries
.read()
.expect("thumbnail entries lock")
.get(id)
.cloned()
.with_context(|| format!("unknown image: {id}"))?;
loop {
let notified = entry.notify.notified();
match entry.state.load(Ordering::Acquire) {
READY if valid_cache_file(&entry.cache_path) => {
return Ok(entry.cache_path.clone());
}
READY => {
if entry
.state
.compare_exchange(READY, PENDING, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
self.queue.push_urgent(id.to_owned());
}
}
FAILED => {
let message = entry
.last_error
.lock()
.expect("thumbnail error lock")
.clone()
.unwrap_or_else(|| "thumbnail generation failed".to_owned());
bail!(message);
}
_ => notified.await,
}
}
}
async fn worker_loop(self: Arc<Self>, worker: usize) {
loop {
let id = self.queue.pop().await;
let entry = self
.entries
.read()
.expect("thumbnail entries lock")
.get(&id)
.cloned();
let Some(entry) = entry else {
continue;
};
if entry
.state
.compare_exchange(PENDING, PROCESSING, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
continue;
}
let record = Arc::clone(&entry.record);
let output = entry.cache_path.clone();
let result = tokio::task::spawn_blocking(move || generate_thumbnail(&record, &output))
.await
.map_err(|error| anyhow!("thumbnail worker stopped: {error}"))
.and_then(|result| result);
match result {
Ok(()) => {
entry.state.store(READY, Ordering::Release);
entry.notify.notify_waiters();
}
Err(error) => {
let attempt = entry.attempts.fetch_add(1, Ordering::AcqRel) + 1;
*entry.last_error.lock().expect("thumbnail error lock") =
Some(error.to_string());
warn!(worker, image = %entry.record.relative_path, attempt, %error, "thumbnail generation failed");
if attempt < MAX_ATTEMPTS {
entry.state.store(PENDING, Ordering::Release);
entry.notify.notify_waiters();
self.schedule_retry(entry, id, attempt);
} else {
entry.state.store(FAILED, Ordering::Release);
entry.notify.notify_waiters();
}
}
}
}
}
fn cache_path(&self, id: &str) -> PathBuf {
shard_path(&self.cache_root, id)
}
fn schedule_retry(self: &Arc<Self>, entry: Arc<ThumbEntry>, id: String, attempt: u8) {
let manager = Arc::clone(self);
tokio::spawn(async move {
let delay = Duration::from_millis(200 * (1 << (attempt - 1)));
tokio::time::sleep(delay).await;
if entry.state.load(Ordering::Acquire) != PENDING {
return;
}
if entry.urgent.load(Ordering::Acquire) {
manager.queue.push_urgent(id);
} else {
manager.queue.push_background(id);
}
});
}
}
impl JobQueue {
fn push_urgent(&self, id: String) {
let should_notify = {
let mut state = self.state.lock().expect("thumbnail queue lock");
if state.queued.contains(&id) {
if let Some(position) = state.background.iter().position(|queued| queued == &id) {
state.background.remove(position);
state.urgent.push_back(id);
true
} else {
false
}
} else {
state.queued.insert(id.clone());
state.urgent.push_back(id);
true
}
};
if !should_notify {
return;
}
self.notify.notify_one();
}
fn push_background(&self, id: String) {
let should_notify = {
let mut state = self.state.lock().expect("thumbnail queue lock");
if state.queued.insert(id.clone()) {
state.background.push_back(id);
true
} else {
false
}
};
if !should_notify {
return;
}
self.notify.notify_one();
}
async fn pop(&self) -> String {
loop {
let notified = self.notify.notified();
if let Some(id) = {
let mut state = self.state.lock().expect("thumbnail queue lock");
let id = state
.urgent
.pop_front()
.or_else(|| state.background.pop_front());
if let Some(id) = &id {
state.queued.remove(id);
}
id
} {
return id;
}
notified.await;
}
}
}
fn valid_cache_file(path: &Path) -> bool {
fs::metadata(path)
.map(|metadata| metadata.is_file() && metadata.len() > 20)
.unwrap_or(false)
}
fn shard_path(root: &Path, id: &str) -> PathBuf {
let shard = id.get(..2).unwrap_or("00");
root.join(shard).join(format!("{id}.webp"))
}
fn generate_thumbnail(record: &ImageRecord, output: &Path) -> Result<()> {
generate_image_cache(
record,
output,
THUMBNAIL_EDGE,
THUMBNAIL_FILTER,
WEBP_QUALITY,
"thumbnail",
)
}
fn generate_preview(record: &ImageRecord, output: &Path) -> Result<()> {
generate_image_cache(
record,
output,
PREVIEW_EDGE,
PREVIEW_FILTER,
PREVIEW_QUALITY,
"viewer preview",
)
}
fn generate_image_cache(
record: &ImageRecord,
output: &Path,
max_edge: u32,
filter: FilterType,
quality: f32,
label: &str,
) -> Result<()> {
let reader = ImageReader::open(&record.path)
.with_context(|| format!("cannot open {}", record.path.display()))?
.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
let mut image = DynamicImage::from_decoder(decoder)?;
image.apply_orientation(orientation);
let (width, height) = image.dimensions();
let image = if width.max(height) > max_edge {
image.resize(max_edge, max_edge, filter)
} else {
image
};
write_webp(&image, output, quality)
.with_context(|| format!("cannot store {label}: {}", output.display()))
}
fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> {
let encoded = match image {
DynamicImage::ImageRgb8(rgb) => {
webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height()).encode(quality)
}
DynamicImage::ImageRgba8(rgba) => {
webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(quality)
}
_ => {
let rgba = image.to_rgba8();
webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(quality)
}
};
let parent = output
.parent()
.with_context(|| format!("invalid image cache path: {}", output.display()))?;
fs::create_dir_all(parent)?;
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let temporary = parent.join(format!(
".{}.{}.{}.tmp",
output
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("image"),
std::process::id(),
sequence
));
let write_result = (|| -> Result<()> {
let mut file = File::create(&temporary)?;
file.write_all(encoded.as_ref())?;
file.flush()?;
drop(file);
fs::rename(&temporary, output)?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&temporary);
}
write_result
}
fn cleanup_cache(root: &Path, valid_ids: &HashSet<String>) {
for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() {
if !entry.file_type().is_file()
|| entry.path().extension().and_then(|ext| ext.to_str()) != Some("webp")
{
continue;
}
let id = entry
.path()
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or_default();
if !valid_ids.contains(id)
&& let Err(error) = fs::remove_file(entry.path())
{
warn!(path = %entry.path().display(), %error, "cannot remove stale thumbnail");
}
}
}
fn purge_temporary_files(root: &Path) {
for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() {
if entry.file_type().is_file()
&& entry.path().extension().and_then(|ext| ext.to_str()) == Some("tmp")
&& let Err(error) = fs::remove_file(entry.path())
{
warn!(path = %entry.path().display(), %error, "cannot remove temporary thumbnail");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gallery::scan_gallery;
#[test]
fn generates_one_bounded_webp_thumbnail() {
let temp = tempfile::tempdir().unwrap();
let gallery = temp.path().join("gallery");
fs::create_dir(&gallery).unwrap();
image::RgbImage::new(1200, 600)
.save(gallery.join("wide.png"))
.unwrap();
let index = scan_gallery(&gallery, None).unwrap();
let output = temp.path().join("thumbnail.webp");
generate_thumbnail(&index.images[0], &output).unwrap();
let generated = image::open(output).unwrap();
assert_eq!(generated.dimensions(), (720, 360));
}
#[test]
fn generates_bounded_viewer_preview_without_upscaling() {
let temp = tempfile::tempdir().unwrap();
let gallery = temp.path().join("gallery");
fs::create_dir(&gallery).unwrap();
image::RgbImage::new(5000, 2500)
.save(gallery.join("wide.png"))
.unwrap();
let index = scan_gallery(&gallery, None).unwrap();
let output = temp.path().join("preview.webp");
generate_preview(&index.images[0], &output).unwrap();
assert_eq!(image::open(output).unwrap().dimensions(), (2560, 1280));
image::RgbImage::new(400, 200)
.save(gallery.join("small.png"))
.unwrap();
let index = scan_gallery(&gallery, None).unwrap();
let small = index
.images
.iter()
.find(|image| image.name == "small.png")
.unwrap();
let output = temp.path().join("small.webp");
generate_preview(small, &output).unwrap();
assert_eq!(image::open(output).unwrap().dimensions(), (400, 200));
}
#[test]
fn queue_promotes_without_duplicate_jobs() {
let queue = JobQueue::default();
queue.push_background("image".to_owned());
queue.push_background("image".to_owned());
queue.push_urgent("image".to_owned());
let state = queue.state.lock().unwrap();
assert_eq!(state.urgent.as_slices().0, ["image"]);
assert!(state.background.is_empty());
assert_eq!(state.queued.len(), 1);
}
}
+351
View File
@@ -0,0 +1,351 @@
use std::{cmp::Reverse, sync::Arc};
use axum::{
Json, Router,
body::Body,
extract::{Path, Query, Request, State},
http::{HeaderValue, Method, StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tower::ServiceExt;
use tower_http::{compression::CompressionLayer, services::ServeFile, trace::TraceLayer};
use tracing::error;
use crate::{
gallery::{Album, GalleryIndex, ImageRecord},
thumbs::{ThumbnailManager, ThumbnailStatus, ViewerPreviewManager},
};
const INDEX_HTML: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/frontend/dist/index.html"
));
const APP_JS: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/frontend/dist/assets/app.js"
));
const APP_CSS: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/frontend/dist/assets/app.css"
));
#[derive(Clone)]
pub struct AppState {
pub index: Arc<RwLock<GalleryIndex>>,
pub thumbnails: Arc<ThumbnailManager>,
pub previews: Arc<ViewerPreviewManager>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImagesQuery {
album: Option<String>,
search: Option<String>,
sort: Option<String>,
offset: Option<usize>,
limit: Option<usize>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GallerySummary {
total: usize,
albums: Vec<Album>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ImagesResponse {
items: Vec<ImageView>,
total: usize,
offset: usize,
limit: usize,
next_offset: Option<usize>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ImageView {
id: String,
name: String,
album: String,
relative_path: String,
width: u32,
height: u32,
size: u64,
modified_ms: u64,
thumbnail_url: String,
preview_url: String,
original_url: String,
}
impl From<&ImageRecord> for ImageView {
fn from(record: &ImageRecord) -> Self {
Self {
id: record.id.clone(),
name: record.name.clone(),
album: record.album.clone(),
relative_path: record.relative_path.clone(),
width: record.width,
height: record.height,
size: record.size,
modified_ms: record.modified_ms,
thumbnail_url: format!("/api/images/{}/thumbnail", record.id),
preview_url: format!("/api/images/{}/preview", record.id),
original_url: format!("/api/images/{}/original", record.id),
}
}
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/api/health", get(health))
.route("/api/gallery", get(gallery_summary))
.route("/api/images", get(images))
.route("/api/status", get(thumbnail_status))
.route("/api/images/{id}/thumbnail", get(thumbnail))
.route("/api/images/{id}/preview", get(preview))
.route("/api/images/{id}/original", get(original))
.route("/assets/app.js", get(app_js))
.route("/assets/app.css", get(app_css))
.fallback(frontend)
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state)
}
async fn health() -> &'static str {
"ok"
}
async fn gallery_summary(State(state): State<AppState>) -> Json<GallerySummary> {
let index = state.index.read().await;
Json(GallerySummary {
total: index.images.len(),
albums: index.albums.clone(),
})
}
async fn images(
State(state): State<AppState>,
Query(query): Query<ImagesQuery>,
) -> Json<ImagesResponse> {
let index = state.index.read().await;
let search = query
.search
.as_deref()
.map(str::trim)
.filter(|search| !search.is_empty())
.map(str::to_lowercase);
let album = query.album.as_deref().filter(|album| !album.is_empty());
let mut matches: Vec<&Arc<ImageRecord>> = index
.images
.iter()
.filter(|record| album.is_none_or(|album| record.album == album))
.filter(|record| {
search
.as_ref()
.is_none_or(|search| record.relative_path.to_lowercase().contains(search))
})
.collect();
match query.sort.as_deref() {
Some("name-desc") => matches.reverse(),
Some("newest") => matches.sort_by_key(|record| Reverse(record.modified_ms)),
_ => {}
}
let total = matches.len();
let offset = query.offset.unwrap_or(0).min(total);
let limit = query.limit.unwrap_or(60).clamp(1, 200);
let end = offset.saturating_add(limit).min(total);
let items = matches[offset..end]
.iter()
.map(|record| ImageView::from(record.as_ref()))
.collect();
Json(ImagesResponse {
items,
total,
offset,
limit,
next_offset: (end < total).then_some(end),
})
}
async fn thumbnail_status(State(state): State<AppState>) -> Json<ThumbnailStatus> {
Json(state.thumbnails.status())
}
async fn thumbnail(
State(state): State<AppState>,
Path(id): Path<String>,
request: Request,
) -> Result<Response, StatusCode> {
if state.index.read().await.get(&id).is_none() {
return Err(StatusCode::NOT_FOUND);
}
let path = state.thumbnails.ensure_ready(&id).await.map_err(|error| {
error!(%id, %error, "cannot serve thumbnail");
StatusCode::INTERNAL_SERVER_ERROR
})?;
serve_file(
path,
request,
&format!("\"thumb-{id}\""),
"public, max-age=31536000, immutable",
)
.await
}
async fn preview(
State(state): State<AppState>,
Path(id): Path<String>,
request: Request,
) -> Result<Response, StatusCode> {
if state.index.read().await.get(&id).is_none() {
return Err(StatusCode::NOT_FOUND);
}
let path = state.previews.ensure_ready(&id).await.map_err(|error| {
error!(%id, %error, "cannot serve viewer preview");
StatusCode::INTERNAL_SERVER_ERROR
})?;
serve_file(
path,
request,
&format!("\"preview-{id}\""),
"public, max-age=31536000, immutable",
)
.await
}
async fn original(
State(state): State<AppState>,
Path(id): Path<String>,
request: Request,
) -> Result<Response, StatusCode> {
let record = state
.index
.read()
.await
.get(&id)
.ok_or(StatusCode::NOT_FOUND)?;
serve_file(
record.path.clone(),
request,
&format!("\"image-{id}\""),
"private, max-age=3600",
)
.await
}
async fn serve_file(
path: std::path::PathBuf,
request: Request,
etag: &str,
cache_control: &'static str,
) -> Result<Response, StatusCode> {
if request
.headers()
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
== Some(etag)
{
let mut response = StatusCode::NOT_MODIFIED.into_response();
add_cache_headers(&mut response, etag, cache_control)?;
return Ok(response);
}
let response = ServeFile::new(path)
.oneshot(request)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (parts, body) = response.into_parts();
let mut response = Response::from_parts(parts, Body::new(body));
add_cache_headers(&mut response, etag, cache_control)?;
Ok(response)
}
fn add_cache_headers(
response: &mut Response,
etag: &str,
cache_control: &'static str,
) -> Result<(), StatusCode> {
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
);
response.headers_mut().insert(
header::ETAG,
HeaderValue::from_str(etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
Ok(())
}
async fn app_js() -> Response {
embedded(APP_JS, "text/javascript; charset=utf-8", true)
}
async fn app_css() -> Response {
embedded(APP_CSS, "text/css; charset=utf-8", true)
}
async fn frontend(request: Request) -> Response {
if request.method() != Method::GET && request.method() != Method::HEAD {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
embedded(INDEX_HTML, "text/html; charset=utf-8", false)
}
fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool) -> Response {
let mut response = Response::new(Body::from(content));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(if immutable {
"public, max-age=31536000, immutable"
} else {
"no-cache"
}),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_view_uses_versioned_id_urls() {
let record = ImageRecord {
id: "abc".into(),
path: "photo.jpg".into(),
relative_path: "album/photo.jpg".into(),
name: "photo.jpg".into(),
album: "album".into(),
width: 10,
height: 20,
size: 30,
modified_ms: 40,
modified_ns: 40,
};
let view = ImageView::from(&record);
assert_eq!(view.thumbnail_url, "/api/images/abc/thumbnail");
assert_eq!(view.preview_url, "/api/images/abc/preview");
assert_eq!(view.original_url, "/api/images/abc/original");
}
}