diff --git a/Cargo.lock b/Cargo.lock index fea2c17..85e833f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -124,6 +139,27 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bytemuck" version = "1.25.2" @@ -166,6 +202,7 @@ version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ + "brotli", "compression-core", "flate2", "memchr", @@ -581,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pixhelf" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "axum", @@ -590,6 +627,7 @@ dependencies = [ "mime_guess", "natord", "serde", + "serde_json", "tempfile", "tokio", "tower", diff --git a/Cargo.toml b/Cargo.toml index f8cc267..1ba69f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pixhelf" -version = "0.1.2" +version = "0.1.3" edition = "2024" build = "build.rs" @@ -12,9 +12,10 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn mime_guess = "2.0.5" natord = "1.0.9" serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.149" 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"] } +tower-http = { version = "0.6.11", features = ["compression-br", "compression-gzip", "fs", "set-header", "trace"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } walkdir = "2.5.0" diff --git a/README.md b/README.md index 73c6c38..89c7653 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pixhelf -基于 Axum 和 React 的自托管图片画廊。React 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。 +基于 Axum 和 Preact 的自托管图片画廊。Preact 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。 ## 运行 @@ -10,7 +10,9 @@ ./target/release/pixhelf ``` -HTTP 服务会立即开放,并优先生成首批 60 张、最长边 720px 的 WebP 缩略图。其余缩略图由后台任务持续处理;新增、修改或删除图片会在定期扫描后自动同步。图库为空时也可正常启动并等待后续图片。 +HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图片只生成一套最长边 720px、质量 82 的高质量 WebP 缩略图;其余缩略图由后台任务持续处理。新增、修改或删除图片会在定期扫描后自动同步,图库为空时也可正常启动并等待后续图片。 + +首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。 缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。 diff --git a/build.rs b/build.rs index 641d08c..8fe703d 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf, process::Command}; +use std::{env, fs, path::PathBuf, process::Command}; fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory")); @@ -16,15 +16,32 @@ fn main() { } println!("cargo:rerun-if-env-changed=PIXHELF_SKIP_FRONTEND_BUILD"); - if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() { - return; + if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_none() { + 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"); } - 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"); + let asset_version = asset_version(&frontend_dir); + println!("cargo:rustc-env=PIXHELF_ASSET_VERSION={asset_version}"); +} + +fn asset_version(frontend_dir: &std::path::Path) -> String { + let mut hash = 0xcbf29ce484222325u64; + for relative in ["dist/assets/app.js", "dist/assets/app.css"] { + let path = frontend_dir.join(relative); + let content = fs::read(&path) + .unwrap_or_else(|error| panic!("cannot read built asset {}: {error}", path.display())); + for byte in relative.bytes().chain(content) { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + format!("{hash:016x}") } diff --git a/frontend/index.html b/frontend/index.html index 2b66b8b..12beca6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,11 +8,13 @@ /> + Pixhelf
+ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e159ee5..46e29f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,308 +1,21 @@ { "name": "pixhelf-frontend", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pixhelf-frontend", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { - "lucide-react": "^0.539.0", - "react": "^19.1.1", - "react-dom": "^19.1.1" + "preact": "^10.27.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" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -745,56 +458,6 @@ "node": ">=18" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -815,13 +478,6 @@ "node": "^22.20 || ^24.12 || >=25" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", @@ -1211,51 +867,6 @@ "win32" ] }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1263,154 +874,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", - "devOptional": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.16.tgz", - "integrity": "sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.411", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.411.tgz", - "integrity": "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg==", - "dev": true, - "license": "ISC" - }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1453,16 +916,6 @@ "@esbuild/win32-x64": "0.28.2" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1496,75 +949,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lucide-react": { - "version": "0.539.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz", - "integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1584,16 +968,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1656,35 +1030,22 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" }, "peerDependencies": { - "react": "^19.2.8" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } } }, "node_modules/rollup": { @@ -1733,22 +1094,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1790,37 +1135,6 @@ "node": ">=14.17" } }, - "node_modules/update-browserslist-db": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", - "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1895,13 +1209,6 @@ "optional": true } } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" } } } diff --git a/frontend/package.json b/frontend/package.json index 0350275..51768eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "pixhelf-frontend", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", @@ -10,14 +10,9 @@ "visual-check": "node scripts/visual-check.mjs" }, "dependencies": { - "lucide-react": "^0.539.0", - "react": "^19.1.1", - "react-dom": "^19.1.1" + "preact": "^10.27.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" diff --git a/frontend/scripts/visual-check.mjs b/frontend/scripts/visual-check.mjs index 3cd82c2..523c4a3 100644 --- a/frontend/scripts/visual-check.mjs +++ b/frontend/scripts/visual-check.mjs @@ -246,6 +246,86 @@ try { searchValue: document.querySelector("#gallery-search-field input")?.value, })); + let mobileCardNames = null; + if (target.name === "mobile") { + const firstCard = page.locator("[data-image-id]").first(); + const secondCard = page.locator("[data-image-id]").nth(1); + await firstCard.dispatchEvent("pointerdown", { + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: "touch", + isPrimary: true, + }); + await page.waitForFunction(() => + document.querySelector("[data-image-id]")?.getAttribute("data-name-visible") === "true" + ); + const shownOnContact = await firstCard.getAttribute("data-name-visible"); + await firstCard.dispatchEvent("pointerup", { + bubbles: true, + cancelable: true, + pointerId: 1, + pointerType: "touch", + isPrimary: true, + }); + const retainedOnRelease = await firstCard.getAttribute("data-name-visible"); + await page.waitForTimeout(1_900); + const retainedWithoutTimeout = await firstCard.getAttribute("data-name-visible"); + + await secondCard.dispatchEvent("pointerdown", { + bubbles: true, + cancelable: true, + pointerId: 2, + pointerType: "touch", + isPrimary: true, + }); + await page.waitForFunction(() => + document.querySelectorAll('[data-name-visible="true"]').length === 1 && + document.querySelectorAll("[data-image-id]")[1] + ?.getAttribute("data-name-visible") === "true" + ); + await page.waitForTimeout(180); + const switchedToSecond = await secondCard.getAttribute("data-name-visible"); + const firstHidden = await firstCard.getAttribute("data-name-visible"); + const activeCount = await page.locator('[data-name-visible="true"]').count(); + const displayedCount = await page.locator('[data-name-visible="true"] .image-name') + .evaluateAll((names) => names.filter((name) => + Number.parseFloat(getComputedStyle(name).opacity) > 0.99 + ).length); + + await secondCard.dispatchEvent("pointerup", { + bubbles: true, + cancelable: true, + pointerId: 2, + pointerType: "touch", + isPrimary: true, + }); + await secondCard.evaluate((card) => { + globalThis.__pixhelfCardClickCount = 0; + card.addEventListener("click", () => { + globalThis.__pixhelfCardClickCount += 1; + }, { once: true }); + }); + const secondCardBox = await secondCard.boundingBox(); + if (secondCardBox) { + await page.touchscreen.tap( + secondCardBox.x + secondCardBox.width / 2, + secondCardBox.y + secondCardBox.height / 2, + ); + } + const clickCount = await page.evaluate(() => globalThis.__pixhelfCardClickCount ?? 0); + mobileCardNames = { + shownOnContact, + retainedOnRelease, + retainedWithoutTimeout, + switchedToSecond, + firstHidden, + activeCount, + displayedCount, + clickCount, + }; + } + let desktopSidebar = null; let mobileNavigation = null; if (target.name === "desktop") { @@ -429,6 +509,7 @@ try { searchDisclosure, exploration, homeNavigation, + mobileCardNames, desktopSidebar, mobileNavigation, browserErrors, @@ -554,6 +635,14 @@ try { mobileNavigation.drawerWidth < 200 || mobileNavigation.closed !== 0 || mobileNavigation.restored !== "false" || + mobileCardNames?.shownOnContact !== "true" || + mobileCardNames.retainedOnRelease !== "true" || + mobileCardNames.retainedWithoutTimeout !== "true" || + mobileCardNames.switchedToSecond !== "true" || + mobileCardNames.firstHidden !== "false" || + mobileCardNames.activeCount !== 1 || + mobileCardNames.displayedCount !== 1 || + mobileCardNames.clickCount !== 1 || layout.cardNameDisplay === "none" || layout.cardNameOpacity > 0.01 || layout.cardNameWhiteSpace !== "nowrap" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afeae4e..01d0abb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { Check, Dices, Folder, - Image as ImageIcon, + ImageIcon, Images, LoaderCircle, PanelLeftClose, @@ -10,20 +10,19 @@ import { RefreshCw, Search, X, -} from "lucide-react"; +} from "./icons"; +import type { CSSProperties, RefObject } from "preact"; +import { memo } from "preact/compat"; import { - type CSSProperties, - type RefObject, - memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, -} from "react"; +} from "preact/hooks"; import { ToolbarPopover } from "./ToolbarPopover"; -import { getGallery, getImages, getStatus } from "./api"; +import { getGallery, getImages, getStatus, takeInitialBootstrap } from "./api"; import type { GalleryImage, GallerySummary, @@ -31,12 +30,16 @@ import type { } from "./types"; const PAGE_SIZE = 60; -const MOBILE_PAGE_SIZE = 36; +const MOBILE_PAGE_SIZE = 48; +const CARD_PREFETCH_MARGIN = "1200px 0px"; +const MOBILE_PAGE_PREFETCH_MARGIN = "1400px 0px"; +const DESKTOP_PAGE_PREFETCH_MARGIN = "900px 0px"; 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"); +const INITIAL_BOOTSTRAP = takeInitialBootstrap(); type ImagePageState = { images: GalleryImage[]; @@ -164,15 +167,69 @@ function createExploreSeed(): string { return Array.from(values, (value) => value.toString(16).padStart(8, "0")).join(""); } +const CARD_LOAD_CALLBACKS = new WeakMap void>(); +let cardLoadObserver: IntersectionObserver | null = null; + +function observeCardLoad(element: Element, load: () => void): () => void { + let observing = false; + const startObserving = () => { + if (!("IntersectionObserver" in window)) { + load(); + return; + } + if (!cardLoadObserver) { + cardLoadObserver = new IntersectionObserver( + (entries, observer) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const callback = CARD_LOAD_CALLBACKS.get(entry.target); + CARD_LOAD_CALLBACKS.delete(entry.target); + observer.unobserve(entry.target); + callback?.(); + } + }, + { rootMargin: CARD_PREFETCH_MARGIN }, + ); + } + observing = true; + CARD_LOAD_CALLBACKS.set(element, load); + cardLoadObserver.observe(element); + }; + + if (document.readyState === "complete") { + startObserving(); + } else { + window.addEventListener("load", startObserving, { once: true }); + } + return () => { + window.removeEventListener("load", startObserving); + if (!observing) return; + CARD_LOAD_CALLBACKS.delete(element); + cardLoadObserver?.unobserve(element); + }; +} + function App() { useVisualViewportTop(); - const [summary, setSummary] = useState(null); - const [status, setStatus] = useState(null); - const [imagePage, setImagePage] = useState(EMPTY_IMAGE_PAGE); + const [summary, setSummary] = useState( + INITIAL_BOOTSTRAP?.summary ?? null, + ); + const [status, setStatus] = useState( + INITIAL_BOOTSTRAP?.status ?? null, + ); + const [imagePage, setImagePage] = useState(() => ( + INITIAL_BOOTSTRAP + ? { + images: INITIAL_BOOTSTRAP.images.items, + total: INITIAL_BOOTSTRAP.images.total, + nextOffset: INITIAL_BOOTSTRAP.images.nextOffset, + } + : EMPTY_IMAGE_PAGE + )); const [album, setAlbum] = useState(""); const [search, setSearch] = useState(""); const [exploreSeed, setExploreSeed] = useState(""); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(!INITIAL_BOOTSTRAP); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [mobileNavOpen, setMobileNavOpen] = useState(false); @@ -183,7 +240,10 @@ function App() { const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE; const debouncedSearch = useDebounced(search.trim(), 250); const requestVersionRef = useRef(0); - const summaryRevisionRef = useRef(null); + const summaryRevisionRef = useRef( + INITIAL_BOOTSTRAP?.summary.revision ?? null, + ); + const skipInitialImagesRef = useRef(Boolean(INITIAL_BOOTSTRAP)); const loadMoreControllerRef = useRef(null); const loadingMoreRef = useRef(false); const { images, total, nextOffset } = imagePage; @@ -226,6 +286,7 @@ function App() { }, [sidebarCollapsed]); useEffect(() => { + if (reloadToken === 0 && INITIAL_BOOTSTRAP) return; const controller = new AbortController(); getGallery(controller.signal) .then((next) => { @@ -281,7 +342,10 @@ function App() { if (!controller.signal.aborted) timer = window.setTimeout(poll, 5000); } }; - void poll(); + const initialDelay = INITIAL_BOOTSTRAP + ? (INITIAL_BOOTSTRAP.status.backgroundComplete ? 10_000 : 1_500) + : 0; + timer = window.setTimeout(poll, initialDelay); return () => { controller.abort(); window.clearTimeout(timer); @@ -289,6 +353,10 @@ function App() { }, []); useEffect(() => { + if (skipInitialImagesRef.current) { + skipInitialImagesRef.current = false; + return; + } const controller = new AbortController(); const requestVersion = ++requestVersionRef.current; loadMoreControllerRef.current?.abort(); @@ -382,7 +450,11 @@ function App() { ([entry]) => { if (entry.isIntersecting) void loadMore(); }, - { rootMargin: compactLayout ? "280px 0px" : "600px 0px" }, + { + rootMargin: compactLayout + ? MOBILE_PAGE_PREFETCH_MARGIN + : DESKTOP_PAGE_PREFETCH_MARGIN, + }, ); observer.observe(sentinel); return () => observer.disconnect(); @@ -616,7 +688,7 @@ function Header({ ref={searchInputRef} type="search" value={search} - onChange={(event) => onSearchChange(event.target.value)} + onInput={(event) => onSearchChange(event.currentTarget.value)} placeholder="搜索文件名" aria-label="搜索文件名" enterKeyHint="search" @@ -853,20 +925,10 @@ function MasonryGallery({ ); const settledRevealIdsRef = useRef(new Set()); const revealRowMembersRef = useRef([]); - const activeNameTimerRef = useRef(0); const columnCount = useColumnCount(ref, initialColumnCount); - const eagerRowCount = columnCount <= 2 ? 6 : 2; - const highPriorityRowCount = columnCount <= 2 ? 4 : 1; + const eagerRowCount = 1; - useEffect(() => () => window.clearTimeout(activeNameTimerRef.current), []); - - const revealName = useCallback((id: string) => { - window.clearTimeout(activeNameTimerRef.current); - setActiveNameId(id); - activeNameTimerRef.current = window.setTimeout(() => { - setActiveNameId((current) => current === id ? null : current); - }, 1_800); - }, []); + const showName = useCallback((id: string) => setActiveNameId(id), []); const markRevealImageSettled = useCallback((rowIndex: number, id: string) => { settledRevealIdsRef.current.add(id); @@ -895,9 +957,8 @@ function MasonryGallery({ return result; }, [columnCount, images]); - const synchronizedRowCount = columnCount <= 2 - ? Math.max(0, ...columns.map((column) => column.length)) - : 0; + // Masonry row indices drift apart vertically, so only coordinate the first row. + const synchronizedRowCount = columnCount <= 2 && images.length > 0 ? 1 : 0; const synchronizedRowMembers = useMemo( () => Array.from({ length: synchronizedRowCount }, (_, rowIndex) => ( columns @@ -925,13 +986,12 @@ function MasonryGallery({ ); @@ -944,31 +1004,41 @@ function MasonryGallery({ const ImageCard = memo(function ImageCard({ image, - index, eager, highPriority, revealReady, revealGroup, nameVisible, - onNameTrigger, + onNameTouch, onSettled, }: { image: GalleryImage; - index: number; eager: boolean; highPriority: boolean; revealReady: boolean; revealGroup?: number; nameVisible: boolean; - onNameTrigger: (id: string) => void; + onNameTouch: (id: string) => void; onSettled?: (rowIndex: number, id: string) => void; }) { const [loaded, setLoaded] = useState(false); const [failed, setFailed] = useState(false); const [attempt, setAttempt] = useState(0); + const [loadRequested, setLoadRequested] = useState(eager); + const cardRef = useRef(null); const retryTimerRef = useRef(0); useEffect(() => () => window.clearTimeout(retryTimerRef.current), []); + useEffect(() => { + if (eager) { + setLoadRequested(true); + return; + } + if (loadRequested) return; + const card = cardRef.current; + if (!card) return; + return observeCardLoad(card, () => setLoadRequested(true)); + }, [eager, loadRequested]); const handleError = () => { setLoaded(false); @@ -984,12 +1054,13 @@ const ImageCard = memo(function ImageCard({ }, delay); }; - const thumbnailUrl = attempt - ? `${image.thumbnailUrl}?retry=${attempt}` - : image.thumbnailUrl; + const thumbnailUrl = `/api/images/${encodeURIComponent(image.id)}/thumbnail`; + const retryQuery = attempt ? `retry=${attempt}` : ""; + const imageUrl = retryQuery ? `${thumbnailUrl}?${retryQuery}` : thumbnailUrl; const revealed = loaded && revealReady; return (
{ - if (event.pointerType !== "mouse") onNameTrigger(image.id); + onPointerDown={(event) => { + if (event.pointerType !== "mouse") onNameTouch(image.id); }} > - {!failed ? ( + {!failed && loadRequested ? ( {image.name} - ) : ( + ) : failed ? ( - )} + ) : null} {image.name}
); diff --git a/frontend/src/ToolbarPopover.tsx b/frontend/src/ToolbarPopover.tsx index 81fc28d..b60a322 100644 --- a/frontend/src/ToolbarPopover.tsx +++ b/frontend/src/ToolbarPopover.tsx @@ -1,13 +1,10 @@ -import { - type ReactNode, - useEffect, - useRef, -} from "react"; +import type { ComponentChildren } from "preact"; +import { useEffect, useRef } from "preact/hooks"; type ToolbarPopoverProps = { - children: ReactNode; + children: ComponentChildren; closeLabel: string; - icon: ReactNode; + icon: ComponentChildren; id: string; open: boolean; openLabel: string; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6ed2b32..ddc503d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,5 @@ import type { + BootstrapData, GallerySummary, ImagesPage, SortMode, @@ -24,7 +25,7 @@ async function getJson(url: string, signal?: AbortSignal): Promise { try { const response = await fetch(url, { - cache: "no-store", + cache: "no-cache", headers: { Accept: "application/json" }, signal: controller.signal, }); @@ -49,6 +50,21 @@ async function getJson(url: string, signal?: AbortSignal): Promise { } } +export function takeInitialBootstrap(): BootstrapData | null { + const element = document.getElementById("pixhelf-bootstrap"); + if (!element) return null; + + try { + const source = element.textContent?.trim(); + if (!source || source.startsWith("__PIXHELF_")) return null; + return JSON.parse(source) as BootstrapData; + } catch { + return null; + } finally { + element.remove(); + } +} + export function getGallery(signal?: AbortSignal): Promise { return getJson("/api/gallery", signal); } diff --git a/frontend/src/icons.tsx b/frontend/src/icons.tsx new file mode 100644 index 0000000..b4b774b --- /dev/null +++ b/frontend/src/icons.tsx @@ -0,0 +1,114 @@ +import type { ComponentChildren, JSX } from "preact"; + +// Minimal Lucide icon subset, distributed under the ISC license. + +type IconProps = Omit, "size"> & { + size?: number | string; + strokeWidth?: number | string; +}; + +function Icon({ + children, + size = 24, + strokeWidth = 2, + ...props +}: IconProps & { children: ComponentChildren }) { + return ( + + {children} + + ); +} + +export function Check(props: IconProps) { + return ; +} + +export function Dices(props: IconProps) { + return ( + + + + + + + ); +} + +export function Folder(props: IconProps) { + return ; +} + +export function ImageIcon(props: IconProps) { + return ( + + + + + + ); +} + +export function Images(props: IconProps) { + return ( + + + + + + + ); +} + +export function LoaderCircle(props: IconProps) { + return ; +} + +export function PanelLeftClose(props: IconProps) { + return ( + + + + + ); +} + +export function PanelLeftOpen(props: IconProps) { + return ( + + + + + ); +} + +export function RefreshCw(props: IconProps) { + return ( + + + + + + + ); +} + +export function Search(props: IconProps) { + return ; +} + +export function X(props: IconProps) { + return ; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e548f90..d485e35 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,8 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; +import { render } from "preact"; import App from "./App"; import "./styles.css"; const root = document.getElementById("root"); if (!root) throw new Error("Missing #root application container"); -createRoot(root).render( - - - , -); +render(, root); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index fec8859..290cb18 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3,7 +3,7 @@ background: #f5f7f6; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; - text-rendering: optimizeLegibility; + text-rendering: auto; --topbar-height: 62px; --sidebar-width: 238px; --sidebar-rail-width: 0px; @@ -746,7 +746,6 @@ select:focus-visible { grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); align-items: start; gap: 6px; - animation: gallery-in var(--motion-medium) ease-out both; } .masonry-column { @@ -768,7 +767,6 @@ select:focus-visible { border: 1px solid rgba(26, 35, 31, 0.08); border-radius: 5px; box-shadow: 0 1px 1px rgba(21, 31, 27, 0.05); - animation: card-in 300ms var(--ease-emphasized) calc(var(--card-order) * 12ms) backwards; transition: border-color var(--motion-medium) ease, box-shadow var(--motion-medium) var(--ease-standard), @@ -786,6 +784,10 @@ select:focus-visible { background-size: 220% 100%, 100% 100%; opacity: 1; transition: opacity var(--motion-medium) ease; + animation: none; +} + +.image-card[data-eager="true"]::before { animation: card-shimmer 1.35s ease-in-out infinite; } @@ -815,6 +817,12 @@ select:focus-visible { transform: scale(1); } +.image-card[data-high-priority="true"] img { + opacity: 1; + transform: scale(1); + transition: none; +} + .image-name { position: absolute; inset: auto 4px 4px; @@ -851,16 +859,6 @@ select:focus-visible { animation: feedback-in var(--motion-medium) ease-out both; } -@keyframes gallery-in { - from { opacity: .72; } - to { opacity: 1; } -} - -@keyframes card-in { - from { opacity: 0; transform: translateY(8px) scale(.992); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - @keyframes card-shimmer { from { background-position: 130% 0, 0 0; } to { background-position: -120% 0, 0 0; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 415f643..4007525 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -13,13 +13,8 @@ export interface GallerySummary { export interface GalleryImage { id: string; name: string; - album: string; - relativePath: string; width: number; height: number; - size: number; - modifiedMs: number; - thumbnailUrl: string; } export interface ImagesPage { @@ -40,4 +35,10 @@ export interface ThumbnailStatus { backgroundComplete: boolean; } +export interface BootstrapData { + summary: GallerySummary; + status: ThumbnailStatus; + images: ImagesPage; +} + export type SortMode = "name-asc" | "name-desc" | "newest" | "explore"; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 369be0a..414256f 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -16,9 +16,9 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "jsxImportSource": "preact", "noUnusedLocals": true, "noUnusedParameters": true }, "include": ["src"] } - diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ef07d13..4f1cb5d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,6 @@ import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; export default defineConfig({ - plugins: [react()], build: { target: "es2022", outDir: "dist", @@ -18,4 +16,3 @@ export default defineConfig({ }, }, }); - diff --git a/src/gallery.rs b/src/gallery.rs index a369c01..d241a95 100644 --- a/src/gallery.rs +++ b/src/gallery.rs @@ -17,6 +17,7 @@ pub struct ImageRecord { pub id: String, pub path: PathBuf, pub relative_path: String, + pub(crate) search_key: String, pub name: String, pub album: String, pub width: u32, @@ -130,11 +131,13 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result) { if path.extension().and_then(|ext| ext.to_str()) != Some("webp") { return; } - let id = path + let stem = path .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or_default(); - if !valid_ids.contains(id) + if !valid_ids.contains(stem) && let Err(error) = fs::remove_file(path) { warn!(path = %path.display(), %error, "cannot remove stale thumbnail"); @@ -627,6 +627,27 @@ mod tests { assert_eq!(generated.dimensions(), (720, 360)); } + #[test] + fn cleanup_keeps_only_the_single_high_quality_variant() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let id = "abcdef"; + let thumbnail = shard_path(root, id); + let old_compact = thumbnail.with_file_name(format!("{id}-360.webp")); + let stale = shard_path(root, "stale"); + fs::create_dir_all(thumbnail.parent().unwrap()).unwrap(); + fs::create_dir_all(stale.parent().unwrap()).unwrap(); + fs::write(&thumbnail, [1; 32]).unwrap(); + fs::write(&old_compact, [1; 32]).unwrap(); + fs::write(&stale, [1; 32]).unwrap(); + + cleanup_cache(root, &HashSet::from([id.to_owned()])); + + assert!(thumbnail.exists()); + assert!(!old_compact.exists()); + assert!(!stale.exists()); + } + #[test] fn queue_promotes_without_duplicate_jobs() { let queue = JobQueue::default(); diff --git a/src/web.rs b/src/web.rs index cee1ec1..b79154b 100644 --- a/src/web.rs +++ b/src/web.rs @@ -4,14 +4,18 @@ use axum::{ Json, Router, body::Body, extract::{Path, Query, Request, State}, - http::{HeaderValue, Method, StatusCode, header}, + http::{HeaderMap, 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 tower_http::{ + compression::{CompressionLayer, CompressionLevel}, + services::ServeFile, + trace::TraceLayer, +}; use tracing::error; use crate::{ @@ -31,6 +35,8 @@ const APP_CSS: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/frontend/dist/assets/app.css" )); +const ASSET_VERSION: &str = env!("PIXHELF_ASSET_VERSION"); +const BOOTSTRAP_PAGE_SIZE: usize = 60; #[derive(Clone)] pub struct AppState { @@ -50,7 +56,7 @@ struct ImagesQuery { limit: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize)] #[serde(rename_all = "kebab-case")] enum ImageSort { #[default] @@ -83,13 +89,8 @@ struct ImagesResponse { struct ImageView { id: String, name: String, - album: String, - relative_path: String, width: u32, height: u32, - size: u64, - modified_ms: u64, - thumbnail_url: String, } impl From<&ImageRecord> for ImageView { @@ -97,17 +98,19 @@ impl From<&ImageRecord> for ImageView { 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), } } } +#[derive(Serialize)] +struct BootstrapResponse { + summary: GallerySummary, + status: ThumbnailStatus, + images: ImagesResponse, +} + pub fn router(state: AppState) -> Router { Router::new() .route("/api/health", get(health)) @@ -115,10 +118,10 @@ pub fn router(state: AppState) -> Router { .route("/api/images", get(images)) .route("/api/status", get(thumbnail_status)) .route("/api/images/{id}/thumbnail", get(thumbnail)) - .route("/assets/app.js", get(app_js)) - .route("/assets/app.css", get(app_css)) + .route("/assets/{version}/app.js", get(app_js)) + .route("/assets/{version}/app.css", get(app_css)) .fallback(frontend) - .layer(CompressionLayer::new()) + .layer(CompressionLayer::new().quality(CompressionLevel::Precise(5))) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -127,20 +130,43 @@ async fn health() -> &'static str { "ok" } -async fn gallery_summary(State(state): State) -> Json { +async fn gallery_summary(State(state): State, headers: HeaderMap) -> Response { let index = state.index.read().await; - Json(GallerySummary { + let etag = revision_etag("gallery", &index.revision); + if is_not_modified(&headers, &etag) { + return not_modified(&etag, "private, no-cache"); + } + + let mut response = Json(summary_from_index(&index)).into_response(); + let _ = add_cache_headers(&mut response, &etag, "private, no-cache"); + response +} + +fn summary_from_index(index: &GalleryIndex) -> GallerySummary { + GallerySummary { total: index.images.len(), albums: index.albums.clone(), revision: index.revision.clone(), - }) + } } async fn images( State(state): State, Query(query): Query, -) -> Json { - let records = state.index.read().await.images.clone(); + headers: HeaderMap, +) -> Response { + let index = state.index.read().await; + let etag = images_etag(&index.revision, &query); + if is_not_modified(&headers, &etag) { + return not_modified(&etag, "private, no-cache"); + } + + let mut response = Json(image_page(&index, &query)).into_response(); + let _ = add_cache_headers(&mut response, &etag, "private, no-cache"); + response +} + +fn image_page(index: &GalleryIndex, query: &ImagesQuery) -> ImagesResponse { let search = query .search .as_deref() @@ -148,52 +174,137 @@ async fn images( .filter(|search| !search.is_empty()) .map(str::to_lowercase); let album = query.album.as_deref().filter(|album| !album.is_empty()); - - let mut matches: Vec> = records - .into_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(); + let requested_offset = query.offset.unwrap_or(0); + let limit = query.limit.unwrap_or(60).clamp(1, 200); match query.sort { - ImageSort::NameAsc => {} - ImageSort::NameDesc => matches.reverse(), - ImageSort::Newest => matches.sort_by_key(|record| Reverse(record.modified_ms)), - ImageSort::Explore => { - let seed_key = - *blake3::hash(query.seed.as_deref().unwrap_or_default().as_bytes()).as_bytes(); - matches.sort_by_cached_key(|record| exploration_rank(&seed_key, &record.id)); + ImageSort::NameAsc if album.is_none() && search.is_none() => { + page_from_unfiltered_index(index, false, requested_offset, limit) + } + ImageSort::NameDesc if album.is_none() && search.is_none() => { + page_from_unfiltered_index(index, true, requested_offset, limit) + } + ImageSort::NameAsc => page_from_ordered_records( + index.images.iter().map(Arc::as_ref), + album, + search.as_deref(), + requested_offset, + limit, + ), + ImageSort::NameDesc => page_from_ordered_records( + index.images.iter().rev().map(Arc::as_ref), + album, + search.as_deref(), + requested_offset, + limit, + ), + ImageSort::Newest | ImageSort::Explore => { + let mut matches: Vec<&ImageRecord> = index + .images + .iter() + .map(Arc::as_ref) + .filter(|record| record_matches(record, album, search.as_deref())) + .collect(); + + match query.sort { + ImageSort::Newest => matches.sort_by_key(|record| Reverse(record.modified_ms)), + ImageSort::Explore => { + let seed_key = + *blake3::hash(query.seed.as_deref().unwrap_or_default().as_bytes()) + .as_bytes(); + matches.sort_by_cached_key(|record| exploration_rank(&seed_key, &record.id)); + } + ImageSort::NameAsc | ImageSort::NameDesc => unreachable!(), + } + page_from_sorted_records(matches, requested_offset, limit) } } +} - 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() +fn page_from_unfiltered_index( + index: &GalleryIndex, + reverse: bool, + requested_offset: usize, + limit: usize, +) -> ImagesResponse { + let total = index.images.len(); + let offset = requested_offset.min(total); + let records: Box> + '_> = if reverse { + Box::new(index.images.iter().rev().skip(offset).take(limit)) + } else { + Box::new(index.images.iter().skip(offset).take(limit)) + }; + let items = records .map(|record| ImageView::from(record.as_ref())) .collect(); + finish_page(items, total, offset, limit) +} - Json(ImagesResponse { +fn record_matches(record: &ImageRecord, album: Option<&str>, search: Option<&str>) -> bool { + album.is_none_or(|album| record.album == album) + && search.is_none_or(|search| record.search_key.contains(search)) +} + +fn page_from_ordered_records<'a>( + records: impl Iterator, + album: Option<&str>, + search: Option<&str>, + requested_offset: usize, + limit: usize, +) -> ImagesResponse { + let mut items = Vec::with_capacity(limit); + let mut total = 0usize; + for record in records.filter(|record| record_matches(record, album, search)) { + if total >= requested_offset && items.len() < limit { + items.push(ImageView::from(record)); + } + total += 1; + } + finish_page(items, total, requested_offset, limit) +} + +fn page_from_sorted_records( + records: Vec<&ImageRecord>, + requested_offset: usize, + limit: usize, +) -> ImagesResponse { + let total = records.len(); + let offset = requested_offset.min(total); + let end = offset.saturating_add(limit).min(total); + let items = records[offset..end] + .iter() + .map(|record| ImageView::from(*record)) + .collect(); + finish_page(items, total, offset, limit) +} + +fn finish_page( + items: Vec, + total: usize, + requested_offset: usize, + limit: usize, +) -> ImagesResponse { + let offset = requested_offset.min(total); + let end = offset.saturating_add(items.len()).min(total); + ImagesResponse { items, total, offset, limit, next_offset: (end < total).then_some(end), - }) + } } fn exploration_rank(seed_key: &[u8; 32], image_id: &str) -> [u8; 32] { *blake3::keyed_hash(seed_key, image_id.as_bytes()).as_bytes() } -async fn thumbnail_status(State(state): State) -> Json { - Json(state.thumbnails.status()) +async fn thumbnail_status(State(state): State) -> Response { + let mut response = Json(state.thumbnails.status()).into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response } async fn thumbnail( @@ -265,37 +376,166 @@ fn add_cache_headers( Ok(()) } -async fn app_js() -> Response { - embedded(APP_JS, "text/javascript; charset=utf-8") +fn revision_etag(kind: &str, revision: &str) -> String { + format!("\"{kind}-{revision}\"") } -async fn app_css() -> Response { - embedded(APP_CSS, "text/css; charset=utf-8") +fn images_etag(revision: &str, query: &ImagesQuery) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"pixhelf-images-query-v1\0"); + hasher.update(query.album.as_deref().unwrap_or_default().as_bytes()); + hasher.update(b"\0"); + hasher.update(query.search.as_deref().unwrap_or_default().as_bytes()); + hasher.update(b"\0"); + hasher.update(&[query.sort as u8]); + hasher.update(query.seed.as_deref().unwrap_or_default().as_bytes()); + hasher.update(&query.offset.unwrap_or_default().to_le_bytes()); + hasher.update(&query.limit.unwrap_or(60).to_le_bytes()); + let query_hash = hasher.finalize().to_hex(); + format!("\"images-{revision}-{}\"", &query_hash[..16]) } -async fn frontend(request: Request) -> Response { +fn is_not_modified(headers: &HeaderMap, etag: &str) -> bool { + headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + .is_some_and(|values| { + values + .split(',') + .map(str::trim) + .any(|value| value == etag || value == "*") + }) +} + +fn not_modified(etag: &str, cache_control: &'static str) -> Response { + let mut response = StatusCode::NOT_MODIFIED.into_response(); + let _ = add_cache_headers(&mut response, etag, cache_control); + response +} + +async fn app_js(Path(version): Path, headers: HeaderMap) -> Response { + embedded_asset( + &version, + APP_JS, + "text/javascript; charset=utf-8", + "js", + &headers, + ) +} + +async fn app_css(Path(version): Path, headers: HeaderMap) -> Response { + embedded_asset( + &version, + APP_CSS, + "text/css; charset=utf-8", + "css", + &headers, + ) +} + +fn embedded_asset( + version: &str, + content: &'static [u8], + content_type: &'static str, + kind: &str, + headers: &HeaderMap, +) -> Response { + if version != ASSET_VERSION { + return StatusCode::NOT_FOUND.into_response(); + } + let etag = format!("\"asset-{kind}-{ASSET_VERSION}\""); + if is_not_modified(headers, &etag) { + return not_modified(&etag, "public, max-age=31536000, immutable"); + } + + let mut response = Response::new(Body::from(content)); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + let _ = add_cache_headers(&mut response, &etag, "public, max-age=31536000, immutable"); + response +} + +async fn frontend(State(state): State, request: Request) -> Response { if request.uri().path() != "/" { return StatusCode::NOT_FOUND.into_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") + let index = state.index.read().await; + let status = state.thumbnails.status(); + let etag = format!( + "\"index-{ASSET_VERSION}-{}-{}-{}\"", + index.revision, status.ready, status.failed + ); + if is_not_modified(request.headers(), &etag) { + return not_modified(&etag, "private, no-cache"); + } + let summary = summary_from_index(&index); + let images = image_page( + &index, + &ImagesQuery { + limit: Some(BOOTSTRAP_PAGE_SIZE), + ..ImagesQuery::default() + }, + ); + + let preload = images.items.first().map_or_else(String::new, |image| { + let url = format!("/api/images/{}/thumbnail", image.id); + format!(r#""#) + }); + let bootstrap = BootstrapResponse { + summary, + status, + images, + }; + let bootstrap_json = match serde_json::to_string(&bootstrap) { + Ok(json) => escape_script_json(json), + Err(error) => { + error!(%error, "cannot serialize frontend bootstrap"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + let inline_styles = format!( + "", + std::str::from_utf8(APP_CSS).expect("Vite app.css must be UTF-8") + ); + let html = std::str::from_utf8(INDEX_HTML) + .expect("Vite index.html must be UTF-8") + .replace( + r#""#, + &inline_styles, + ) + .replace("/assets/app.js", &format!("/assets/{ASSET_VERSION}/app.js")) + .replace( + "/assets/app.css", + &format!("/assets/{ASSET_VERSION}/app.css"), + ) + .replace("", &preload) + .replace("__PIXHELF_BOOTSTRAP__", &bootstrap_json); + drop(index); + + let body = if request.method() == Method::HEAD { + Body::empty() + } else { + Body::from(html) + }; + let mut response = Response::new(body); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + let _ = add_cache_headers(&mut response, &etag, "private, no-cache"); + response } -fn embedded(content: &'static [u8], content_type: &'static str) -> 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("no-cache")); - response.headers_mut().insert( - header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - ); - response +fn escape_script_json(json: String) -> String { + json.replace('&', "\\u0026") + .replace('<', "\\u003c") + .replace('>', "\\u003e") + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029") } #[cfg(test)] @@ -319,11 +559,12 @@ mod tests { } #[test] - fn image_view_uses_versioned_id_urls() { + fn image_view_keeps_the_payload_compact() { let record = ImageRecord { id: "abc".into(), path: "photo.jpg".into(), relative_path: "album/photo.jpg".into(), + search_key: "album/photo.jpg".into(), name: "photo.jpg".into(), album: "album".into(), width: 10, @@ -333,7 +574,11 @@ mod tests { modified_ns: 40, }; let view = ImageView::from(&record); - assert_eq!(view.thumbnail_url, "/api/images/abc/thumbnail"); + let json = serde_json::to_string(&view).unwrap(); + assert_eq!( + json, + r#"{"id":"abc","name":"photo.jpg","width":10,"height":20}"# + ); } #[test] @@ -385,9 +630,57 @@ mod tests { } #[tokio::test] - async fn fixed_asset_names_are_revalidated() { - let response = request("/assets/app.js").await; + async fn versioned_assets_are_immutable() { + let response = request(&format!("/assets/{ASSET_VERSION}/app.js")).await; assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache"); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert_eq!( + request("/assets/stale/app.js").await.status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn frontend_embeds_bootstrap_and_versioned_assets() { + let response = request("/").await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = std::str::from_utf8(&body).unwrap(); + assert!(html.contains(&format!("/assets/{ASSET_VERSION}/app.js"))); + assert!(html.contains("