From d2d7c23cbd03aff634f7ab1636f2d347f7aad931 Mon Sep 17 00:00:00 2001 From: adminroot Date: Sat, 22 Aug 2026 10:33:52 +0000 Subject: [PATCH] Release v0.1.2 --- Cargo.lock | 2 +- Cargo.toml | 3 +- README.md | 6 +- build.rs | 1 + frontend/index.html | 6 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/scripts/visual-check.mjs | 696 ++++++++---- frontend/src/App.tsx | 869 +++++++++++---- frontend/src/ImageViewer.tsx | 855 --------------- frontend/src/ToolbarPopover.tsx | 99 ++ frontend/src/api.ts | 47 +- frontend/src/main.tsx | 6 +- frontend/src/styles.css | 1376 ++++++++++++------------ frontend/src/types.ts | 5 +- frontend/src/viewer/useViewerEngine.ts | 826 -------------- src/config.rs | 70 +- src/gallery.rs | 113 +- src/main.rs | 58 +- src/thumbs.rs | 612 +++++------ src/web.rs | 200 ++-- 21 files changed, 2558 insertions(+), 3298 deletions(-) delete mode 100644 frontend/src/ImageViewer.tsx create mode 100644 frontend/src/ToolbarPopover.tsx delete mode 100644 frontend/src/viewer/useViewerEngine.ts diff --git a/Cargo.lock b/Cargo.lock index 145f677..fea2c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,7 +581,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pixhelf" -version = "0.1.0" +version = "0.1.2" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index be89e25..f8cc267 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pixhelf" -version = "0.1.0" +version = "0.1.2" edition = "2024" build = "build.rs" @@ -26,4 +26,3 @@ tempfile = "3.27.0" [profile.release] lto = "thin" strip = "symbols" - diff --git a/README.md b/README.md index e01249e..73c6c38 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ ./target/release/pixhelf ``` -首次启动会先生成首批 60 张、最长边 720px 的 WebP 缩略图,然后开放 HTTP 服务。其余缩略图由后台任务持续处理;打开图片时,查看器会按需生成独立的最长边 2560px WebP 预览。新增、修改或删除图片会在定期扫描后自动同步。 +HTTP 服务会立即开放,并优先生成首批 60 张、最长边 720px 的 WebP 缩略图。其余缩略图由后台任务持续处理;新增、修改或删除图片会在定期扫描后自动同步。图库为空时也可正常启动并等待后续图片。 -缩略图和查看器预览缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。 +缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。 ## 构建 @@ -31,7 +31,7 @@ cargo build --release ```text --gallery-dir PATH 图库目录,默认 ./pic ---cache-dir PATH 缩略图和查看器预览缓存,默认 ./.pixhelf-cache/thumbnails +--cache-dir PATH 缩略图缓存,默认 ./.pixhelf-cache/thumbnails --listen HOST:PORT 监听地址,默认 0.0.0.0:3002 --initial-batch N 启动前生成数量,默认 60 --workers N 后台压缩并发数,默认按 CPU 自动选择 2-4 diff --git a/build.rs b/build.rs index f7ee41a..641d08c 100644 --- a/build.rs +++ b/build.rs @@ -14,6 +14,7 @@ fn main() { ] { println!("cargo:rerun-if-changed={path}"); } + println!("cargo:rerun-if-env-changed=PIXHELF_SKIP_FRONTEND_BUILD"); if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() { return; diff --git a/frontend/index.html b/frontend/index.html index ed85ebf..2b66b8b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,13 @@ - + + Pixhelf diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb4ba5a..e159ee5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pixhelf-frontend", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixhelf-frontend", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "lucide-react": "^0.539.0", "react": "^19.1.1", diff --git a/frontend/package.json b/frontend/package.json index d722662..0350275 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pixhelf-frontend", "private": true, - "version": "0.1.0", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/frontend/scripts/visual-check.mjs b/frontend/scripts/visual-check.mjs index 26a5084..3cd82c2 100644 --- a/frontend/scripts/visual-check.mjs +++ b/frontend/scripts/visual-check.mjs @@ -13,51 +13,6 @@ const browser = await chromium.launch({ 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 }, @@ -81,198 +36,529 @@ try { const rect = image.getBoundingClientRect(); return rect.bottom > 0 && rect.top < innerHeight; }); - return visible.length >= 8 && visible.every((image) => image.complete && image.naturalWidth > 0); + 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, - })); + const layout = await page.evaluate(() => { + const path = document.querySelector(".desktop-sidebar .sidebar-gallery-path"); + const count = document.querySelector(".desktop-sidebar .sidebar-gallery-count"); + const search = document.querySelector(".topbar-search"); + const topbar = document.querySelector(".topbar"); + const sidebarStatus = document.querySelector(".desktop-sidebar .sidebar-status"); + const sidebarMeta = document.querySelector(".desktop-sidebar .sidebar-gallery-meta"); + const pathRect = path?.getBoundingClientRect(); + const countRect = count?.getBoundingClientRect(); + const searchRect = search?.getBoundingClientRect(); + const exploreRect = document.querySelector(".explore-toggle")?.getBoundingClientRect(); + const topbarRect = topbar?.getBoundingClientRect(); + const statusRect = sidebarStatus?.getBoundingClientRect(); + const metaRect = sidebarMeta?.getBoundingClientRect(); + const galleryToggle = document.querySelector(".gallery-sidebar-toggle"); + return { + viewport: [innerWidth, innerHeight], + bodyWidth: document.documentElement.scrollWidth, + cards: document.querySelectorAll("[data-image-id]").length, + cardTags: [...document.querySelectorAll("[data-image-id]")] + .map((element) => element.tagName), + 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, + dialogs: document.querySelectorAll('[role="dialog"]').length, + topbarPosition: topbar ? getComputedStyle(topbar).position : "", + topbarTop: topbarRect?.top ?? -1, + topbarSearches: document.querySelectorAll( + ".topbar-actions > .topbar-search", + ).length, + brandHomeLinks: document.querySelectorAll('a.brand[href="/"]').length, + gallerySidebarToggles: document.querySelectorAll(".gallery-sidebar-toggle").length, + galleryToggleOpacity: galleryToggle + ? Number.parseFloat(getComputedStyle(galleryToggle).opacity) + : -1, + topbarSidebarToggles: document.querySelectorAll( + ".topbar .sidebar-toggle-button", + ).length, + contentSearches: document.querySelectorAll(".content .search-field").length, + topbarSearchRightGap: searchRect ? innerWidth - searchRect.right : -1, + exploreButtons: document.querySelectorAll(".topbar-actions > .explore-toggle").length, + exploreSearchGap: exploreRect && searchRect ? searchRect.left - exploreRect.right : -1, + sidebarStatuses: document.querySelectorAll( + ".desktop-sidebar > .sidebar-status", + ).length, + sidebarGalleryMetas: document.querySelectorAll(".sidebar-gallery-meta").length, + contentInfoRows: document.querySelectorAll( + ".content-heading, .content .sidebar-gallery-meta", + ).length, + sortControls: document.querySelectorAll(".sort-control").length, + cardNameDisplay: getComputedStyle(document.querySelector(".image-name")).display, + cardNameOpacity: Number.parseFloat( + getComputedStyle(document.querySelector(".image-name")).opacity, + ), + cardNameWhiteSpace: getComputedStyle( + document.querySelector(".image-name"), + ).whiteSpace, + sidebarMetaBottomGap: metaRect ? innerHeight - metaRect.bottom : -1, + sidebarMetaStatusGap: statusRect && metaRect ? metaRect.top - statusRect.bottom : -1, + galleryPath: path?.textContent ?? "", + galleryCount: count?.textContent ?? "", + galleryMetaRowDelta: pathRect && countRect + ? Math.abs(pathRect.top - countRect.top) + : -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` }); + await page.waitForTimeout(200); + const dialogsAfterImageClick = await page.locator('[role="dialog"]').count(); - 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); + const searchToggleBefore = await page.locator(".search-toggle").boundingBox(); + await page.locator(".search-toggle").click(); + await page.waitForFunction(() => + document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "true" + ); + await page.waitForTimeout(220); + const searchToggleOpen = await page.locator(".search-toggle").boundingBox(); + const searchPanelOpen = await page.locator("#gallery-search-field").boundingBox(); + const searchDisclosure = await page.evaluate(() => { + const leading = document.querySelector(".topbar-leading")?.getBoundingClientRect(); + const panel = document.querySelector("#gallery-search-field")?.getBoundingClientRect(); + const topbar = document.querySelector(".topbar")?.getBoundingClientRect(); 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, + expanded: document.querySelector(".search-toggle")?.getAttribute("aria-expanded"), + focused: document.activeElement?.matches("#gallery-search-field input") ?? false, + panelRole: document.querySelector("#gallery-search-field")?.getAttribute("role"), + panelInert: document.querySelector("#gallery-search-field")?.inert ?? true, + leadingGap: leading && panel ? panel.left - leading.right : -1, + panelTop: panel?.top ?? -1, + topbarTop: topbar?.top ?? -1, + topbarBottom: topbar?.bottom ?? -1, }; }); - - 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; + searchDisclosure.panelWidth = searchPanelOpen?.width ?? -1; + searchDisclosure.stationaryToggleDelta = searchToggleBefore && searchToggleOpen + ? Math.hypot( + searchToggleBefore.x - searchToggleOpen.x, + searchToggleBefore.y - searchToggleOpen.y, + ) + : -1; + await page.locator("#gallery-search-field input").fill("x"); + await page.waitForFunction(() => + document.querySelector(".topbar-search")?.classList.contains("has-query") + ); + searchDisclosure.hasQueryClass = true; + await page.locator("#gallery-search-field input").fill(""); + await page.waitForFunction(() => + !document.querySelector(".topbar-search")?.classList.contains("has-query") + ); + searchDisclosure.queryClassCleared = true; 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.evaluate(() => { + document.documentElement.style.setProperty("--visual-viewport-top", "24px"); + }); + await page.waitForTimeout(50); + const compensatedTopbar = await page.locator(".topbar").boundingBox(); + const compensatedPanel = await page.locator("#gallery-search-field").boundingBox(); + searchDisclosure.compensatedTopbarDelta = compensatedTopbar + ? compensatedTopbar.y - searchDisclosure.topbarTop + : -1; + searchDisclosure.compensatedPanelDelta = compensatedPanel + ? compensatedPanel.y - searchDisclosure.panelTop + : -1; + await page.evaluate(() => { + document.documentElement.style.setProperty("--visual-viewport-top", "0px"); + }); + await page.waitForTimeout(50); + } + await page.locator(".search-toggle").click(); + await page.waitForFunction(() => + document.querySelector(".search-toggle")?.getAttribute("aria-expanded") === "false" + ); + searchDisclosure.restored = await page.locator(".search-toggle") + .getAttribute("aria-expanded"); + searchDisclosure.panelInertClosed = await page.locator("#gallery-search-field") + .evaluate((panel) => panel.inert); - 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 firstExploreRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return url.pathname === "/api/images" && + url.searchParams.get("sort") === "explore" && + url.searchParams.get("offset") === "0"; + }); + await page.locator(".explore-toggle").click(); + const firstExplore = await firstExploreRequest; + const firstExploreUrl = new URL(firstExplore.url()); + const firstExploreResponse = await firstExplore.response(); + await firstExploreResponse?.finished(); + await page.waitForFunction(() => !document.querySelector(".skeleton-grid")); - 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 }, + const secondExploreRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return url.pathname === "/api/images" && + url.searchParams.get("sort") === "explore" && + url.searchParams.get("offset") === "0"; + }); + await page.locator(".explore-toggle").click(); + const secondExplore = await secondExploreRequest; + const secondExploreUrl = new URL(secondExplore.url()); + const secondExploreResponse = await secondExplore.response(); + await secondExploreResponse?.finished(); + await page.waitForFunction(() => !document.querySelector(".skeleton-grid")); + + const exploration = await page.evaluate(([firstSeed, secondSeed]) => ({ + firstSeed, + secondSeed, + exploreMarker: getComputedStyle( + document.querySelector(".explore-toggle"), + "::after", + ).content, + searchMarker: getComputedStyle( + document.querySelector(".search-toggle"), + "::after", + ).content, + }), [ + firstExploreUrl.searchParams.get("seed") ?? "", + secondExploreUrl.searchParams.get("seed") ?? "", + ]); + + const homeRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return url.pathname === "/api/images" && + url.searchParams.get("sort") === "name-asc" && + url.searchParams.get("offset") === "0"; + }); + await page.locator("a.brand").click(); + const homeResponse = await (await homeRequest).response(); + await homeResponse?.finished(); + await page.waitForFunction(() => !document.querySelector(".skeleton-grid")); + const homeNavigation = await page.evaluate(() => ({ + href: document.querySelector("a.brand")?.getAttribute("href"), + galleryPath: document.querySelector( + ".desktop-sidebar .sidebar-gallery-path", + )?.textContent, + allImagesActive: document.querySelector(".desktop-sidebar .album-link") + ?.classList.contains("active") ?? false, + searchValue: document.querySelector("#gallery-search-field input")?.value, + })); + + let desktopSidebar = null; + let mobileNavigation = null; + if (target.name === "desktop") { + const contentBefore = await page.locator(".content").boundingBox(); + const toggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox(); + const firstAlbumBefore = await page.locator(".desktop-sidebar .album-link").first() + .boundingBox(); + const controlSlotBefore = await page.locator(".desktop-sidebar .sidebar-control-slot") + .boundingBox(); + const metaBefore = await page.locator(".desktop-sidebar .sidebar-gallery-meta") + .boundingBox(); + const galleryBefore = await page.locator(".masonry").boundingBox(); + await page.locator(".gallery-sidebar-toggle").click(); + await page.waitForFunction(() => + document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "true" ); - await page.waitForFunction( - (previous) => document.querySelector(".viewer-counter")?.textContent !== previous, - touchCounterBefore, + await page.waitForTimeout(260); + const contentCollapsed = await page.locator(".content").boundingBox(); + const toggleCollapsed = await page.locator(".gallery-sidebar-toggle").boundingBox(); + const sidebarCollapsedBox = await page.locator(".desktop-sidebar").boundingBox(); + const galleryCollapsed = await page.locator(".masonry").boundingBox(); + desktopSidebar = await page.evaluate(() => ({ + expanded: document.querySelector(".gallery-sidebar-toggle")?.getAttribute("aria-expanded"), + visibility: getComputedStyle(document.querySelector(".desktop-sidebar")).visibility, + navigationVisibility: getComputedStyle( + document.querySelector(".desktop-sidebar .album-nav"), + ).visibility, + statusVisibility: getComputedStyle( + document.querySelector(".desktop-sidebar .sidebar-status"), + ).visibility, + metaVisibility: getComputedStyle( + document.querySelector(".desktop-sidebar .sidebar-gallery-meta"), + ).visibility, + ariaHidden: document.querySelector(".desktop-sidebar")?.getAttribute("aria-hidden"), + stored: localStorage.getItem("pixhelf.sidebar-collapsed"), + columns: document.querySelectorAll(".masonry-column").length, + redundantHeadings: document.querySelectorAll(".sidebar-heading").length, + pathDetails: document.querySelectorAll(".album-copy small").length, + progressPanels: document.querySelectorAll(".sidebar-progress").length, + inlineToggles: document.querySelectorAll(".desktop-sidebar-inline-toggle").length, + galleryToggles: document.querySelectorAll(".gallery-sidebar-toggle").length, + })); + desktopSidebar.contentBefore = contentBefore?.x ?? -1; + desktopSidebar.contentCollapsed = contentCollapsed?.x ?? -1; + desktopSidebar.expandedToggleWidth = toggleBefore?.width ?? -1; + desktopSidebar.expandedToggleHeight = toggleBefore?.height ?? -1; + desktopSidebar.collapsedToggleWidth = toggleCollapsed?.width ?? -1; + desktopSidebar.collapsedToggleHeight = toggleCollapsed?.height ?? -1; + desktopSidebar.albumClearance = controlSlotBefore && firstAlbumBefore + ? firstAlbumBefore.y - (controlSlotBefore.y + controlSlotBefore.height) + : -1; + desktopSidebar.toggleControlInsetX = toggleBefore && controlSlotBefore + ? toggleBefore.x - controlSlotBefore.x + : -1; + desktopSidebar.toggleControlInsetY = toggleBefore && controlSlotBefore + ? toggleBefore.y - controlSlotBefore.y + : -1; + desktopSidebar.stationaryToggleDelta = toggleBefore && toggleCollapsed + ? Math.hypot(toggleBefore.x - toggleCollapsed.x, toggleBefore.y - toggleCollapsed.y) + : -1; + desktopSidebar.metaIsBelowAlbums = metaBefore && firstAlbumBefore + ? metaBefore.y > firstAlbumBefore.y + firstAlbumBefore.height + : false; + desktopSidebar.expandedGalleryInsetX = toggleBefore && galleryBefore + ? toggleBefore.x - galleryBefore.x + : -1; + desktopSidebar.expandedGalleryInsetY = toggleBefore && galleryBefore + ? toggleBefore.y - galleryBefore.y + : -1; + desktopSidebar.collapsedGalleryInsetX = toggleCollapsed && galleryCollapsed + ? toggleCollapsed.x - galleryCollapsed.x + : -1; + desktopSidebar.collapsedGalleryInsetY = toggleCollapsed && galleryCollapsed + ? toggleCollapsed.y - galleryCollapsed.y + : -1; + desktopSidebar.expandedGalleryOverlap = toggleBefore && galleryBefore + ? !( + toggleBefore.x + toggleBefore.width <= galleryBefore.x || + galleryBefore.x + galleryBefore.width <= toggleBefore.x || + toggleBefore.y + toggleBefore.height <= galleryBefore.y || + galleryBefore.y + galleryBefore.height <= toggleBefore.y + ) + : false; + desktopSidebar.galleryOverlap = toggleCollapsed && galleryCollapsed + ? !( + toggleCollapsed.x + toggleCollapsed.width <= galleryCollapsed.x || + galleryCollapsed.x + galleryCollapsed.width <= toggleCollapsed.x || + toggleCollapsed.y + toggleCollapsed.height <= galleryCollapsed.y || + galleryCollapsed.y + galleryCollapsed.height <= toggleCollapsed.y + ) + : true; + desktopSidebar.sidebarRight = sidebarCollapsedBox + ? sidebarCollapsedBox.x + sidebarCollapsedBox.width + : -1; + + await page.locator(".gallery-sidebar-toggle").click(); + await page.waitForFunction(() => + document.querySelector(".app-shell")?.getAttribute("data-sidebar-collapsed") === "false" ); - 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, - }; + await page.waitForTimeout(260); + const toggleRestored = await page.locator(".gallery-sidebar-toggle").boundingBox(); + desktopSidebar.restoredToggleDelta = toggleBefore && toggleRestored + ? Math.hypot(toggleBefore.x - toggleRestored.x, toggleBefore.y - toggleRestored.y) + : -1; + desktopSidebar.restored = await page.locator(".gallery-sidebar-toggle") + .getAttribute("aria-expanded"); } else { - await page.keyboard.press("Escape"); - await page.waitForSelector(".image-viewer", { state: "detached" }); + await page.evaluate(() => window.scrollTo({ top: 320, behavior: "auto" })); + await page.waitForTimeout(100); + const fixedTop = await page.locator(".topbar").boundingBox(); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" })); + const navigationToggleBefore = await page.locator(".gallery-sidebar-toggle").boundingBox(); + const galleryBefore = await page.locator(".masonry").boundingBox(); + await page.locator(".gallery-sidebar-toggle").click(); + await page.waitForSelector(".mobile-nav-layer"); + await page.waitForTimeout(240); + const navigationToggleOpen = await page.locator(".gallery-sidebar-toggle").boundingBox(); + const navigationLayer = await page.locator(".mobile-nav-layer").boundingBox(); + const navigationDrawer = await page.locator(".mobile-sidebar").boundingBox(); + const controlSlot = await page.locator(".mobile-sidebar .sidebar-control-slot").boundingBox(); + const mobileMeta = await page.locator(".mobile-sidebar .sidebar-gallery-meta").boundingBox(); + const mobileStatus = await page.locator(".mobile-sidebar .sidebar-status").boundingBox(); + const topbar = await page.locator(".topbar").boundingBox(); + mobileNavigation = { + opened: await page.locator(".mobile-nav-layer").count(), + fixedTop: fixedTop?.y ?? -1, + expanded: await page.locator(".gallery-sidebar-toggle").getAttribute("aria-expanded"), + internalToggles: await page.locator(".mobile-sidebar .sidebar-toggle-button").count(), + metaRows: await page.locator(".mobile-sidebar .sidebar-gallery-meta").count(), + galleryPath: await page.locator(".mobile-sidebar .sidebar-gallery-path").textContent(), + galleryCount: await page.locator(".mobile-sidebar .sidebar-gallery-count").textContent(), + layerTopGap: navigationLayer && topbar + ? navigationLayer.y - (topbar.y + topbar.height) + : -1, + toggleWidth: navigationToggleBefore?.width ?? -1, + toggleHeight: navigationToggleBefore?.height ?? -1, + expandedToggleWidth: navigationToggleOpen?.width ?? -1, + galleryInsetX: navigationToggleBefore && galleryBefore + ? navigationToggleBefore.x - galleryBefore.x + : -1, + galleryInsetY: navigationToggleBefore && galleryBefore + ? navigationToggleBefore.y - galleryBefore.y + : -1, + galleryOverlap: navigationToggleBefore && galleryBefore + ? !( + navigationToggleBefore.x + navigationToggleBefore.width <= galleryBefore.x || + galleryBefore.x + galleryBefore.width <= navigationToggleBefore.x || + navigationToggleBefore.y + navigationToggleBefore.height <= galleryBefore.y || + galleryBefore.y + galleryBefore.height <= navigationToggleBefore.y + ) + : -1, + stationaryToggleDelta: navigationToggleBefore && navigationToggleOpen + ? Math.hypot( + navigationToggleBefore.x - navigationToggleOpen.x, + navigationToggleBefore.y - navigationToggleOpen.y, + ) + : -1, + toggleControlInsetX: navigationToggleOpen && controlSlot + ? navigationToggleOpen.x - controlSlot.x + : -1, + toggleControlInsetY: navigationToggleOpen && controlSlot + ? navigationToggleOpen.y - controlSlot.y + : -1, + metaBottomGap: mobileMeta ? target.height - (mobileMeta.y + mobileMeta.height) : -1, + metaStatusGap: mobileMeta && mobileStatus + ? mobileMeta.y - (mobileStatus.y + mobileStatus.height) + : -1, + drawerWidth: navigationDrawer?.width ?? -1, + }; + await page.locator(".gallery-sidebar-toggle").click(); + await page.waitForSelector(".mobile-nav-layer", { state: "detached" }); + mobileNavigation.closed = await page.locator(".mobile-nav-layer").count(); + mobileNavigation.restored = await page.locator(".gallery-sidebar-toggle") + .getAttribute("aria-expanded"); } const result = { name: target.name, ...layout, - viewerLayout, - zoomedLayout, - doubleClickZoom, - wheelZoom, - singleClickControls, - counterBefore, - counterAfter, - touchChecks, + dialogsAfterImageClick, + searchDisclosure, + exploration, + homeNavigation, + desktopSidebar, + mobileNavigation, 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 + layout.cardTags.some((tag) => tag !== "FIGURE") || + layout.dialogs !== 0 || + dialogsAfterImageClick !== 0 || + searchDisclosure.expanded !== "true" || + !searchDisclosure.focused || + searchDisclosure.panelRole !== "group" || + searchDisclosure.panelInert || + (target.name === "desktop" && searchDisclosure.leadingGap < 6) || + searchDisclosure.panelWidth < (target.name === "desktop" ? 250 : 200) || + searchDisclosure.stationaryToggleDelta < 0 || + searchDisclosure.stationaryToggleDelta > 0.5 || + searchDisclosure.restored !== "false" || + !searchDisclosure.panelInertClosed || + !searchDisclosure.hasQueryClass || + !searchDisclosure.queryClassCleared || + searchDisclosure.panelTop - searchDisclosure.topbarBottom < 6 || + exploration.firstSeed.length !== 32 || + exploration.secondSeed.length !== 32 || + exploration.firstSeed === exploration.secondSeed || + exploration.exploreMarker !== "none" || + exploration.searchMarker !== "none" || + homeNavigation.href !== "/" || + homeNavigation.galleryPath !== "/" || + !homeNavigation.allImagesActive || + homeNavigation.searchValue !== "" || + layout.topbarSearches !== 1 || + layout.brandHomeLinks !== 1 || + layout.gallerySidebarToggles !== 1 || + layout.galleryToggleOpacity < 0.4 || + layout.galleryToggleOpacity > 0.75 || + layout.topbarSidebarToggles !== 0 || + layout.contentSearches !== 0 || + layout.contentInfoRows !== 0 || + layout.sortControls !== 0 || + layout.topbarSearchRightGap < 0 || + layout.topbarSearchRightGap > 20 || + layout.exploreButtons !== 1 || + layout.exploreSearchGap < 4 || + layout.exploreSearchGap > 8 || + layout.sidebarStatuses !== 1 || + layout.sidebarGalleryMetas !== 1 || + !layout.galleryPath.startsWith("/") || + !layout.galleryCount.endsWith("张图片") || + layout.galleryMetaRowDelta < 0 || + layout.galleryMetaRowDelta > 2 || + (target.name === "desktop" && ( + !desktopSidebar || + desktopSidebar.expanded !== "false" || + desktopSidebar.visibility !== "visible" || + desktopSidebar.navigationVisibility !== "hidden" || + desktopSidebar.statusVisibility !== "hidden" || + desktopSidebar.metaVisibility !== "hidden" || + desktopSidebar.ariaHidden !== "true" || + desktopSidebar.stored !== "true" || + desktopSidebar.columns !== 6 || + desktopSidebar.redundantHeadings !== 0 || + desktopSidebar.pathDetails !== 0 || + desktopSidebar.progressPanels !== 0 || + desktopSidebar.inlineToggles !== 0 || + desktopSidebar.galleryToggles !== 1 || + Math.abs(desktopSidebar.expandedToggleWidth - (desktopSidebar.contentBefore - 20)) > 1 || + Math.abs(desktopSidebar.expandedToggleHeight - 36) > 1 || + Math.abs(desktopSidebar.collapsedToggleWidth - 40) > 1 || + Math.abs(desktopSidebar.collapsedToggleHeight - 36) > 1 || + desktopSidebar.albumClearance < 8 || + desktopSidebar.toggleControlInsetX < 8 || + desktopSidebar.toggleControlInsetX > 12 || + desktopSidebar.toggleControlInsetY < 4 || + desktopSidebar.toggleControlInsetY > 8 || + desktopSidebar.stationaryToggleDelta < 0 || + desktopSidebar.stationaryToggleDelta > 1 || + !desktopSidebar.metaIsBelowAlbums || + desktopSidebar.expandedGalleryOverlap || + !desktopSidebar.galleryOverlap || + Math.abs(desktopSidebar.sidebarRight) > 1 || + desktopSidebar.contentBefore < 200 || + Math.abs(desktopSidebar.contentCollapsed) > 1 || + desktopSidebar.restoredToggleDelta < 0 || + desktopSidebar.restoredToggleDelta > 1 || + layout.sidebarMetaBottomGap < 0 || + layout.sidebarMetaBottomGap > 1 || + Math.abs(layout.sidebarMetaStatusGap) > 1 || + desktopSidebar.restored !== "true" )) || + (target.name === "mobile" && ( + layout.topbarPosition !== "fixed" || + Math.abs(layout.topbarTop) > 1 || + Math.abs(searchDisclosure.compensatedTopbarDelta - 24) > 1 || + Math.abs(searchDisclosure.compensatedPanelDelta - 24) > 1 || + mobileNavigation?.opened !== 1 || + Math.abs(mobileNavigation.fixedTop) > 1 || + mobileNavigation.expanded !== "true" || + mobileNavigation.internalToggles !== 0 || + mobileNavigation.metaRows !== 1 || + !mobileNavigation.galleryPath?.startsWith("/") || + !mobileNavigation.galleryCount?.endsWith("张图片") || + Math.abs(mobileNavigation.layerTopGap) > 1 || + Math.abs(mobileNavigation.toggleWidth - 40) > 1 || + Math.abs(mobileNavigation.toggleHeight - 36) > 1 || + Math.abs(mobileNavigation.expandedToggleWidth - (mobileNavigation.drawerWidth - 20)) > 1 || + !mobileNavigation.galleryOverlap || + mobileNavigation.stationaryToggleDelta < 0 || + mobileNavigation.stationaryToggleDelta > 1 || + mobileNavigation.toggleControlInsetX < 8 || + mobileNavigation.toggleControlInsetX > 12 || + mobileNavigation.toggleControlInsetY < 4 || + mobileNavigation.toggleControlInsetY > 8 || + mobileNavigation.metaBottomGap < 0 || + mobileNavigation.metaBottomGap > 1 || + Math.abs(mobileNavigation.metaStatusGap) > 1 || + mobileNavigation.drawerWidth < 200 || + mobileNavigation.closed !== 0 || + mobileNavigation.restored !== "false" || + layout.cardNameDisplay === "none" || + layout.cardNameOpacity > 0.01 || + layout.cardNameWhiteSpace !== "nowrap" + )) || + (target.name === "desktop" && layout.topbarPosition !== "fixed") || browserErrors.length ) { throw new Error(`visual check failed: ${JSON.stringify(result)}`); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5463f34..afeae4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,12 @@ import { - ArrowDownAZ, Check, + Dices, Folder, Image as ImageIcon, Images, LoaderCircle, - Menu, PanelLeftClose, + PanelLeftOpen, RefreshCw, Search, X, @@ -14,6 +14,7 @@ import { import { type CSSProperties, type RefObject, + memo, useCallback, useEffect, useLayoutEffect, @@ -21,17 +22,41 @@ import { useRef, useState, } from "react"; +import { ToolbarPopover } from "./ToolbarPopover"; 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; +const SIDEBAR_STORAGE_KEY = "pixhelf.sidebar-collapsed"; +const GALLERY_POLL_INTERVAL_MS = 10_000; +const IMAGE_RETRY_DELAYS_MS = [1_000, 3_000] as const; +const MOBILE_NAV_EXIT_MS = 240; +const COUNT_FORMATTER = new Intl.NumberFormat("zh-CN"); + +type ImagePageState = { + images: GalleryImage[]; + total: number; + nextOffset: number | null; +}; + +const EMPTY_IMAGE_PAGE: ImagePageState = { + images: [], + total: 0, + nextOffset: null, +}; + +function initialSidebarCollapsed(): boolean { + try { + return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "true"; + } catch { + return false; + } +} function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => @@ -49,6 +74,43 @@ function useMediaQuery(query: string): boolean { return matches; } +function useVisualViewportTop(): void { + useEffect(() => { + const viewport = window.visualViewport; + if (!viewport) return; + + let frame = 0; + let settleTimer = 0; + const update = () => { + frame = 0; + const pageOffset = viewport.pageTop - window.scrollY; + const offset = Math.max(0, viewport.offsetTop, pageOffset); + document.documentElement.style.setProperty( + "--visual-viewport-top", + `${Math.round(offset * 100) / 100}px`, + ); + }; + const schedule = () => { + if (!frame) frame = window.requestAnimationFrame(update); + window.clearTimeout(settleTimer); + settleTimer = window.setTimeout(update, 120); + }; + + update(); + viewport.addEventListener("resize", schedule); + viewport.addEventListener("scroll", schedule); + window.addEventListener("orientationchange", schedule); + return () => { + if (frame) window.cancelAnimationFrame(frame); + window.clearTimeout(settleTimer); + viewport.removeEventListener("resize", schedule); + viewport.removeEventListener("scroll", schedule); + window.removeEventListener("orientationchange", schedule); + document.documentElement.style.removeProperty("--visual-viewport-top"); + }; + }, []); +} + function useDebounced(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -58,38 +120,80 @@ function useDebounced(value: T, delay: number): T { return debounced; } +function useDelayedUnmount(visible: boolean, delay: number): boolean { + const [mounted, setMounted] = useState(visible); + + useEffect(() => { + if (visible) { + setMounted(true); + return; + } + if (!mounted) return; + + const timer = window.setTimeout(() => setMounted(false), delay); + return () => window.clearTimeout(timer); + }, [delay, mounted, visible]); + + return mounted; +} + function formatCount(value: number): string { - return new Intl.NumberFormat("zh-CN").format(value); + return COUNT_FORMATTER.format(value); +} + +function errorMessage(reason: unknown, fallback: string): string { + return reason instanceof Error ? reason.message : fallback; +} + +function appendUniqueImages( + current: GalleryImage[], + incoming: GalleryImage[], +): GalleryImage[] { + const ids = new Set(current.map((image) => image.id)); + const unique = incoming.filter((image) => { + if (ids.has(image.id)) return false; + ids.add(image.id); + return true; + }); + return unique.length ? [...current, ...unique] : current; +} + +function createExploreSeed(): string { + const values = new Uint32Array(4); + window.crypto.getRandomValues(values); + return Array.from(values, (value) => value.toString(16).padStart(8, "0")).join(""); } function App() { + useVisualViewportTop(); const [summary, setSummary] = useState(null); const [status, setStatus] = useState(null); - const [images, setImages] = useState([]); - const [total, setTotal] = useState(0); - const [nextOffset, setNextOffset] = useState(null); + const [imagePage, setImagePage] = useState(EMPTY_IMAGE_PAGE); const [album, setAlbum] = useState(""); const [search, setSearch] = useState(""); - const [sort, setSort] = useState("name-asc"); + const [exploreSeed, setExploreSeed] = useState(""); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); - const [selectedId, setSelectedId] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(initialSidebarCollapsed); const [reloadToken, setReloadToken] = useState(0); const compactLayout = useMediaQuery("(max-width: 720px)"); + const mobileNavMounted = useDelayedUnmount(mobileNavOpen, MOBILE_NAV_EXIT_MS); 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; + const requestVersionRef = useRef(0); + const summaryRevisionRef = useRef(null); + const loadMoreControllerRef = useRef(null); + const loadingMoreRef = useRef(false); + const { images, total, nextOffset } = imagePage; useLayoutEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); - }, [album, debouncedSearch, sort]); + }, [album, debouncedSearch, exploreSeed]); useEffect(() => { - if (!mobileNavOpen) return; + if (!mobileNavMounted) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const closeOnEscape = (event: KeyboardEvent) => { @@ -100,103 +204,175 @@ function App() { document.body.style.overflow = previousOverflow; window.removeEventListener("keydown", closeOnEscape); }; - }, [mobileNavOpen]); + }, [mobileNavMounted]); useEffect(() => { if (!compactLayout && mobileNavOpen) setMobileNavOpen(false); }, [compactLayout, mobileNavOpen]); + useEffect(() => { + if (summary && album && !summary.albums.some((item) => item.path === album)) { + setAlbum(""); + setMobileNavOpen(false); + } + }, [album, summary]); + + useEffect(() => { + try { + window.localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarCollapsed)); + } catch { + // The layout still works when storage is disabled. + } + }, [sidebarCollapsed]); + useEffect(() => { const controller = new AbortController(); getGallery(controller.signal) - .then(setSummary) + .then((next) => { + summaryRevisionRef.current = next.revision; + setSummary(next); + }) .catch((reason: unknown) => { if (!controller.signal.aborted) { - setError(reason instanceof Error ? reason.message : "无法读取图库"); + setError(errorMessage(reason, "无法读取图库")); } }); return () => controller.abort(); }, [reloadToken]); useEffect(() => { - let cancelled = false; + const controller = new AbortController(); let timer = 0; const poll = async () => { try { - const next = await getStatus(); - if (cancelled) return; - setStatus(next); - timer = window.setTimeout(poll, next.backgroundComplete ? 10000 : 1500); + const next = await getGallery(controller.signal); + if (controller.signal.aborted) return; + const previousRevision = summaryRevisionRef.current; + if (previousRevision !== next.revision) { + summaryRevisionRef.current = next.revision; + setSummary(next); + if (previousRevision) setReloadToken((value) => value + 1); + } } catch { - if (!cancelled) timer = window.setTimeout(poll, 5000); + // A later poll retries transient scan or network failures. + } finally { + if (!controller.signal.aborted) { + timer = window.setTimeout(poll, GALLERY_POLL_INTERVAL_MS); + } } }; - void poll(); + timer = window.setTimeout(poll, GALLERY_POLL_INTERVAL_MS); return () => { - cancelled = true; + controller.abort(); window.clearTimeout(timer); }; }, []); useEffect(() => { - if (status && summary && status.total !== summary.total) { - void getGallery().then(setSummary).catch(() => undefined); - } - }, [status?.total, summary?.total]); + const controller = new AbortController(); + let timer = 0; + const poll = async () => { + try { + const next = await getStatus(controller.signal); + if (controller.signal.aborted) return; + setStatus(next); + timer = window.setTimeout(poll, next.backgroundComplete ? 10000 : 1500); + } catch { + if (!controller.signal.aborted) timer = window.setTimeout(poll, 5000); + } + }; + void poll(); + return () => { + controller.abort(); + window.clearTimeout(timer); + }; + }, []); useEffect(() => { const controller = new AbortController(); + const requestVersion = ++requestVersionRef.current; + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; setLoading(true); + setLoadingMore(false); setError(null); - setImages([]); - setNextOffset(null); + setImagePage(EMPTY_IMAGE_PAGE); getImages( { album, search: debouncedSearch, - sort, + sort: exploreSeed ? "explore" : "name-asc", + seed: exploreSeed, offset: 0, limit: pageSize, }, controller.signal, ) .then((page) => { - setImages(page.items); - setTotal(page.total); - setNextOffset(page.nextOffset); + if (requestVersion !== requestVersionRef.current) return; + setImagePage({ + images: page.items, + total: page.total, + nextOffset: page.nextOffset, + }); }) .catch((reason: unknown) => { if (!controller.signal.aborted) { - setError(reason instanceof Error ? reason.message : "无法读取图片"); + setError(errorMessage(reason, "无法读取图片")); } }) .finally(() => { - if (!controller.signal.aborted) setLoading(false); + if (!controller.signal.aborted && requestVersion === requestVersionRef.current) { + setLoading(false); + } }); - return () => controller.abort(); - }, [album, debouncedSearch, pageSize, reloadToken, sort]); + return () => { + controller.abort(); + const loadMoreController = loadMoreControllerRef.current; + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + loadMoreController?.abort(); + }; + }, [album, debouncedSearch, exploreSeed, pageSize, reloadToken]); const loadMore = useCallback(async () => { - if (nextOffset === null || loadingMore) return; - const queryAtStart = activeQueryRef.current; + if (loading || nextOffset === null || loadingMoreRef.current) return; + const controller = new AbortController(); + const requestVersion = requestVersionRef.current; + loadMoreControllerRef.current = controller; + loadingMoreRef.current = true; 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); + const page = await getImages( + { + album, + search: debouncedSearch, + sort: exploreSeed ? "explore" : "name-asc", + seed: exploreSeed, + offset: nextOffset, + limit: pageSize, + }, + controller.signal, + ); + if (requestVersion !== requestVersionRef.current) return; + setImagePage((current) => ({ + images: appendUniqueImages(current.images, page.items), + total: page.total, + nextOffset: page.nextOffset, + })); } catch (reason) { - setError(reason instanceof Error ? reason.message : "无法继续加载图片"); + if (!controller.signal.aborted && requestVersion === requestVersionRef.current) { + setError(errorMessage(reason, "无法继续加载图片")); + } } finally { - setLoadingMore(false); + if (loadMoreControllerRef.current === controller) { + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + if (requestVersion === requestVersionRef.current) setLoadingMore(false); + } } - }, [album, debouncedSearch, loadingMore, nextOffset, pageSize, sort]); + }, [album, debouncedSearch, exploreSeed, loading, nextOffset, pageSize]); const sentinelRef = useRef(null); useEffect(() => { @@ -212,90 +388,78 @@ function App() { 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 galleryPath = `/${album.replace(/^\/+/, "")}`; const chooseAlbum = (path: string) => { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); setAlbum(path); + setExploreSeed(""); + setMobileNavOpen(false); + }; + const changeSearch = (value: string) => { + setSearch(value); + setExploreSeed(""); + }; + const startExploring = () => { + setExploreSeed(createExploreSeed()); + }; + const goHome = () => { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + setAlbum(""); + setSearch(""); + setExploreSeed(""); setMobileNavOpen(false); }; - return ( -
+
setMobileNavOpen(true)} + search={search} + onSearchChange={changeSearch} + onExplore={startExploring} + exploreActive={Boolean(exploreSeed)} + exploreLoading={Boolean(exploreSeed) && loading} + onHome={goHome} + onToggleNavigation={() => setMobileNavOpen((open) => !open)} navigationOpen={mobileNavOpen} /> +
+ +
+ { + if (compactLayout) { + setMobileNavOpen((open) => !open); + } else { + setSidebarCollapsed((collapsed) => !collapsed); + } + }} + controls={compactLayout ? "mobile-album-navigation" : "desktop-album-navigation"} + /> setMobileNavOpen(false)} - status={status} + desktopCollapsed={sidebarCollapsed} /> -
-
-
-

{album || "图库"}

-
-

{title}

- {formatCount(total)} 张 -
-
-
- - -
-
- +
{error && (
{error} @@ -313,12 +477,17 @@ function App() { {loading ? ( ) : images.length ? ( - + ) : (
- 没有找到图片 - 请调整相册或搜索条件 + {summary?.total === 0 ? "图库暂无图片" : "没有找到图片"} + + {summary?.total === 0 ? "添加图片后将自动显示" : "请调整相册或搜索条件"} +
)} @@ -329,95 +498,227 @@ function App() { )}
- - {selectedIndex >= 0 && ( - setSelectedId(images[index]?.id ?? null)} - onNeedMore={() => void loadMore()} - onClose={closeViewer} - /> - )}
); } function Header({ - status, - onOpenNavigation, + search, + onSearchChange, + onExplore, + exploreActive, + exploreLoading, + onHome, + onToggleNavigation, navigationOpen, }: { - status: ThumbnailStatus | null; - onOpenNavigation: () => void; + search: string; + onSearchChange: (value: string) => void; + onExplore: () => void; + exploreActive: boolean; + exploreLoading: boolean; + onHome: () => void; + onToggleNavigation: () => void; navigationOpen: boolean; }) { + const [searchOpen, setSearchOpen] = useState(false); + const [exploreMotionKey, setExploreMotionKey] = useState(0); + const searchInputRef = useRef(null); + + useEffect(() => { + if (!searchOpen) return; + const frame = window.requestAnimationFrame(() => { + searchInputRef.current?.focus({ preventScroll: true }); + }); + return () => window.cancelAnimationFrame(frame); + }, [searchOpen]); + + useEffect(() => { + if (navigationOpen) { + searchInputRef.current?.blur(); + setSearchOpen(false); + } + }, [navigationOpen]); + + const handleSearchOpenChange = (open: boolean) => { + if (open && navigationOpen) onToggleNavigation(); + setSearchOpen(open); + }; + const handleExplore = () => { + searchInputRef.current?.blur(); + setSearchOpen(false); + if (navigationOpen) onToggleNavigation(); + setExploreMotionKey((value) => value + 1); + onExplore(); + }; + const handleHome = () => { + searchInputRef.current?.blur(); + setSearchOpen(false); + onHome(); + }; + return ( -
- -
- - Pixhelf +
+ +
+ + + +
-
); } -function StatusCompact({ status }: { status: ThumbnailStatus | null }) { - if (!status) return 连接中; +function SidebarToggleButton({ + className, + controls, + expanded, + onClick, +}: { + className: string; + controls: string; + expanded: boolean; + onClick: () => void; +}) { + const label = expanded ? "收起侧栏" : "展开侧栏"; + return ( + + ); +} + +function SidebarStatus({ status }: { status: ThumbnailStatus | null }) { + if (!status) { + return ( +
+ + 连接中 +
+ ); + } const complete = status.backgroundComplete; return (
{complete ? : } - {formatCount(status.ready)} / {formatCount(status.total)} + {complete ? "已就绪" : "处理中"} + {formatCount(status.ready)} / {formatCount(status.total)}
); } function Sidebar({ summary, + status, activeAlbum, + galleryPath, + galleryCount, onChoose, mobileOpen, + mobileMounted, onClose, - status, + desktopCollapsed, }: { summary: GallerySummary | null; + status: ThumbnailStatus | null; activeAlbum: string; + galleryPath: string; + galleryCount: number; onChoose: (path: string) => void; mobileOpen: boolean; + mobileMounted: boolean; onClose: () => void; - status: ThumbnailStatus | null; + desktopCollapsed: boolean; }) { const navigation = ( <> -
- 相册 - -
+