Release v0.1.3

添加查看器前的最后加固
This commit is contained in:
adminroot committed 2026-08-22 12:57:57 +00:00
1 parent d2d7c23cbd
commit e646d6762a
20 files changed
+849 -891

No files matched your search

Generated
+39 -1
View File
@@ -17,6 +17,21 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@@ -124,6 +139,27 @@ dependencies = [
"cpufeatures", "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]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.2" version = "1.25.2"
@@ -166,6 +202,7 @@ version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
dependencies = [ dependencies = [
"brotli",
"compression-core", "compression-core",
"flate2", "flate2",
"memchr", "memchr",
@@ -581,7 +618,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "pixhelf" name = "pixhelf"
version = "0.1.2" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -590,6 +627,7 @@ dependencies = [
"mime_guess", "mime_guess",
"natord", "natord",
"serde", "serde",
"serde_json",
"tempfile", "tempfile",
"tokio", "tokio",
"tower", "tower",
+3 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pixhelf" name = "pixhelf"
version = "0.1.2" version = "0.1.3"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
@@ -12,9 +12,10 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg", "pn
mime_guess = "2.0.5" mime_guess = "2.0.5"
natord = "1.0.9" natord = "1.0.9"
serde = { version = "1.0.229", features = ["derive"] } 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"] } tokio = { version = "1.53.1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tower = { version = "0.5.3", features = ["util"] } 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 = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
walkdir = "2.5.0" walkdir = "2.5.0"
+4 -2
View File
@@ -1,6 +1,6 @@
# Pixhelf # Pixhelf
基于 Axum 和 React 的自托管图片画廊。React 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。 基于 Axum 和 Preact 的自托管图片画廊。Preact 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。
## 运行 ## 运行
@@ -10,7 +10,9 @@
./target/release/pixhelf ./target/release/pixhelf
``` ```
HTTP 服务会立即开放,并优先生成首批 60 张、最长边 720px 的 WebP 缩略图。其余缩略图由后台任务持续处理;新增、修改或删除图片会在定期扫描后自动同步。图库为空时也可正常启动并等待后续图片。 HTTP 服务会立即开放,并优先生成首批 60 张缩略图。每张图片只生成一套最长边 720px、质量 82 的高质量 WebP 缩略图;其余缩略图由后台任务持续处理。新增、修改或删除图片会在定期扫描后自动同步,图库为空时也可正常启动并等待后续图片。
首屏图库数据与压缩后的关键样式会随 HTML 一并下发,首张图片会提前加载。JS 使用内容版本化的长期缓存,API 使用 ETag 协商缓存,并按浏览器能力启用 Brotli 或 gzip 压缩。
缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。 缩略图缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。
+27 -10
View File
@@ -1,4 +1,4 @@
use std::{env, path::PathBuf, process::Command}; use std::{env, fs, path::PathBuf, process::Command};
fn main() { fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory")); 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"); println!("cargo:rerun-if-env-changed=PIXHELF_SKIP_FRONTEND_BUILD");
if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() { if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_none() {
return; let status = Command::new("npm")
.args(["run", "build", "--silent"])
.current_dir(&frontend_dir)
.status()
.expect(
"failed to run the frontend build; install Node.js and run `npm ci` in frontend/",
);
assert!(status.success(), "frontend build failed");
} }
let status = Command::new("npm") let asset_version = asset_version(&frontend_dir);
.args(["run", "build", "--silent"]) println!("cargo:rustc-env=PIXHELF_ASSET_VERSION={asset_version}");
.current_dir(frontend_dir) }
.status()
.expect("failed to run the frontend build; install Node.js and run `npm ci` in frontend/"); fn asset_version(frontend_dir: &std::path::Path) -> String {
let mut hash = 0xcbf29ce484222325u64;
assert!(status.success(), "frontend build failed"); 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}")
} }
+2
View File
@@ -8,11 +8,13 @@
/> />
<meta name="theme-color" content="#f4f5f4" /> <meta name="theme-color" content="#f4f5f4" />
<meta name="description" content="Pixhelf 私人图片画廊" /> <meta name="description" content="Pixhelf 私人图片画廊" />
<!--PIXHELF_IMAGE_PRELOAD-->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23287a72'/%3E%3Cpath d='M8 8h7v7H8zm9 0h7v7h-7zM8 17h7v7H8zm9 0h7v7h-7z' fill='white'/%3E%3C/svg%3E" /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23287a72'/%3E%3Cpath d='M8 8h7v7H8zm9 0h7v7h-7zM8 17h7v7H8zm9 0h7v7h-7z' fill='white'/%3E%3C/svg%3E" />
<title>Pixhelf</title> <title>Pixhelf</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script id="pixhelf-bootstrap" type="application/json">__PIXHELF_BOOTSTRAP__</script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+16 -709
View File
@@ -1,308 +1,21 @@
{ {
"name": "pixhelf-frontend", "name": "pixhelf-frontend",
"version": "0.1.2", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pixhelf-frontend", "name": "pixhelf-frontend",
"version": "0.1.2", "version": "0.1.3",
"dependencies": { "dependencies": {
"lucide-react": "^0.539.0", "preact": "^10.27.1"
"react": "^19.1.1",
"react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"playwright-core": "^1.62.1", "playwright-core": "^1.62.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.3" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -745,56 +458,6 @@
"node": ">=18" "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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "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": "^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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4", "version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
@@ -1211,51 +867,6 @@
"win32" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1263,154 +874,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esbuild": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1453,16 +916,6 @@
"@esbuild/win32-x64": "0.28.2" "@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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "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": "^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": { "node_modules/nanoid": {
"version": "3.3.18", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "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": "^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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1656,35 +1030,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/react": { "node_modules/preact": {
"version": "19.2.8", "version": "10.29.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT", "license": "MIT",
"engines": { "funding": {
"node": ">=0.10.0" "type": "opencollective",
} "url": "https://opencollective.com/preact"
},
"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"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.8" "preact-render-to-string": ">=5"
} },
}, "peerDependenciesMeta": {
"node_modules/react-refresh": { "preact-render-to-string": {
"version": "0.17.0", "optional": true
"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"
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
@@ -1733,22 +1094,6 @@
"fsevents": "~2.3.2" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1790,37 +1135,6 @@
"node": ">=14.17" "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": { "node_modules/vite": {
"version": "7.3.6", "version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1895,13 +1209,6 @@
"optional": true "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"
} }
} }
} }
+2 -7
View File
@@ -1,7 +1,7 @@
{ {
"name": "pixhelf-frontend", "name": "pixhelf-frontend",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
@@ -10,14 +10,9 @@
"visual-check": "node scripts/visual-check.mjs" "visual-check": "node scripts/visual-check.mjs"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.539.0", "preact": "^10.27.1"
"react": "^19.1.1",
"react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"playwright-core": "^1.62.1", "playwright-core": "^1.62.1",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"vite": "^7.1.3" "vite": "^7.1.3"
+89
View File
@@ -246,6 +246,86 @@ try {
searchValue: document.querySelector("#gallery-search-field input")?.value, 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 desktopSidebar = null;
let mobileNavigation = null; let mobileNavigation = null;
if (target.name === "desktop") { if (target.name === "desktop") {
@@ -429,6 +509,7 @@ try {
searchDisclosure, searchDisclosure,
exploration, exploration,
homeNavigation, homeNavigation,
mobileCardNames,
desktopSidebar, desktopSidebar,
mobileNavigation, mobileNavigation,
browserErrors, browserErrors,
@@ -554,6 +635,14 @@ try {
mobileNavigation.drawerWidth < 200 || mobileNavigation.drawerWidth < 200 ||
mobileNavigation.closed !== 0 || mobileNavigation.closed !== 0 ||
mobileNavigation.restored !== "false" || 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.cardNameDisplay === "none" ||
layout.cardNameOpacity > 0.01 || layout.cardNameOpacity > 0.01 ||
layout.cardNameWhiteSpace !== "nowrap" layout.cardNameWhiteSpace !== "nowrap"
+121 -49
View File
@@ -2,7 +2,7 @@ import {
Check, Check,
Dices, Dices,
Folder, Folder,
Image as ImageIcon, ImageIcon,
Images, Images,
LoaderCircle, LoaderCircle,
PanelLeftClose, PanelLeftClose,
@@ -10,20 +10,19 @@ import {
RefreshCw, RefreshCw,
Search, Search,
X, X,
} from "lucide-react"; } from "./icons";
import type { CSSProperties, RefObject } from "preact";
import { memo } from "preact/compat";
import { import {
type CSSProperties,
type RefObject,
memo,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "preact/hooks";
import { ToolbarPopover } from "./ToolbarPopover"; import { ToolbarPopover } from "./ToolbarPopover";
import { getGallery, getImages, getStatus } from "./api"; import { getGallery, getImages, getStatus, takeInitialBootstrap } from "./api";
import type { import type {
GalleryImage, GalleryImage,
GallerySummary, GallerySummary,
@@ -31,12 +30,16 @@ import type {
} from "./types"; } from "./types";
const PAGE_SIZE = 60; 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 SIDEBAR_STORAGE_KEY = "pixhelf.sidebar-collapsed";
const GALLERY_POLL_INTERVAL_MS = 10_000; const GALLERY_POLL_INTERVAL_MS = 10_000;
const IMAGE_RETRY_DELAYS_MS = [1_000, 3_000] as const; const IMAGE_RETRY_DELAYS_MS = [1_000, 3_000] as const;
const MOBILE_NAV_EXIT_MS = 240; const MOBILE_NAV_EXIT_MS = 240;
const COUNT_FORMATTER = new Intl.NumberFormat("zh-CN"); const COUNT_FORMATTER = new Intl.NumberFormat("zh-CN");
const INITIAL_BOOTSTRAP = takeInitialBootstrap();
type ImagePageState = { type ImagePageState = {
images: GalleryImage[]; images: GalleryImage[];
@@ -164,15 +167,69 @@ function createExploreSeed(): string {
return Array.from(values, (value) => value.toString(16).padStart(8, "0")).join(""); return Array.from(values, (value) => value.toString(16).padStart(8, "0")).join("");
} }
const CARD_LOAD_CALLBACKS = new WeakMap<Element, () => 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() { function App() {
useVisualViewportTop(); useVisualViewportTop();
const [summary, setSummary] = useState<GallerySummary | null>(null); const [summary, setSummary] = useState<GallerySummary | null>(
const [status, setStatus] = useState<ThumbnailStatus | null>(null); INITIAL_BOOTSTRAP?.summary ?? null,
const [imagePage, setImagePage] = useState<ImagePageState>(EMPTY_IMAGE_PAGE); );
const [status, setStatus] = useState<ThumbnailStatus | null>(
INITIAL_BOOTSTRAP?.status ?? null,
);
const [imagePage, setImagePage] = useState<ImagePageState>(() => (
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 [album, setAlbum] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [exploreSeed, setExploreSeed] = useState(""); const [exploreSeed, setExploreSeed] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(!INITIAL_BOOTSTRAP);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
@@ -183,7 +240,10 @@ function App() {
const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE; const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE;
const debouncedSearch = useDebounced(search.trim(), 250); const debouncedSearch = useDebounced(search.trim(), 250);
const requestVersionRef = useRef(0); const requestVersionRef = useRef(0);
const summaryRevisionRef = useRef<string | null>(null); const summaryRevisionRef = useRef<string | null>(
INITIAL_BOOTSTRAP?.summary.revision ?? null,
);
const skipInitialImagesRef = useRef(Boolean(INITIAL_BOOTSTRAP));
const loadMoreControllerRef = useRef<AbortController | null>(null); const loadMoreControllerRef = useRef<AbortController | null>(null);
const loadingMoreRef = useRef(false); const loadingMoreRef = useRef(false);
const { images, total, nextOffset } = imagePage; const { images, total, nextOffset } = imagePage;
@@ -226,6 +286,7 @@ function App() {
}, [sidebarCollapsed]); }, [sidebarCollapsed]);
useEffect(() => { useEffect(() => {
if (reloadToken === 0 && INITIAL_BOOTSTRAP) return;
const controller = new AbortController(); const controller = new AbortController();
getGallery(controller.signal) getGallery(controller.signal)
.then((next) => { .then((next) => {
@@ -281,7 +342,10 @@ function App() {
if (!controller.signal.aborted) timer = window.setTimeout(poll, 5000); 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 () => { return () => {
controller.abort(); controller.abort();
window.clearTimeout(timer); window.clearTimeout(timer);
@@ -289,6 +353,10 @@ function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (skipInitialImagesRef.current) {
skipInitialImagesRef.current = false;
return;
}
const controller = new AbortController(); const controller = new AbortController();
const requestVersion = ++requestVersionRef.current; const requestVersion = ++requestVersionRef.current;
loadMoreControllerRef.current?.abort(); loadMoreControllerRef.current?.abort();
@@ -382,7 +450,11 @@ function App() {
([entry]) => { ([entry]) => {
if (entry.isIntersecting) void loadMore(); if (entry.isIntersecting) void loadMore();
}, },
{ rootMargin: compactLayout ? "280px 0px" : "600px 0px" }, {
rootMargin: compactLayout
? MOBILE_PAGE_PREFETCH_MARGIN
: DESKTOP_PAGE_PREFETCH_MARGIN,
},
); );
observer.observe(sentinel); observer.observe(sentinel);
return () => observer.disconnect(); return () => observer.disconnect();
@@ -616,7 +688,7 @@ function Header({
ref={searchInputRef} ref={searchInputRef}
type="search" type="search"
value={search} value={search}
onChange={(event) => onSearchChange(event.target.value)} onInput={(event) => onSearchChange(event.currentTarget.value)}
placeholder="搜索文件名" placeholder="搜索文件名"
aria-label="搜索文件名" aria-label="搜索文件名"
enterKeyHint="search" enterKeyHint="search"
@@ -853,20 +925,10 @@ function MasonryGallery({
); );
const settledRevealIdsRef = useRef(new Set<string>()); const settledRevealIdsRef = useRef(new Set<string>());
const revealRowMembersRef = useRef<string[][]>([]); const revealRowMembersRef = useRef<string[][]>([]);
const activeNameTimerRef = useRef(0);
const columnCount = useColumnCount(ref, initialColumnCount); const columnCount = useColumnCount(ref, initialColumnCount);
const eagerRowCount = columnCount <= 2 ? 6 : 2; const eagerRowCount = 1;
const highPriorityRowCount = columnCount <= 2 ? 4 : 1;
useEffect(() => () => window.clearTimeout(activeNameTimerRef.current), []); const showName = useCallback((id: string) => setActiveNameId(id), []);
const revealName = useCallback((id: string) => {
window.clearTimeout(activeNameTimerRef.current);
setActiveNameId(id);
activeNameTimerRef.current = window.setTimeout(() => {
setActiveNameId((current) => current === id ? null : current);
}, 1_800);
}, []);
const markRevealImageSettled = useCallback((rowIndex: number, id: string) => { const markRevealImageSettled = useCallback((rowIndex: number, id: string) => {
settledRevealIdsRef.current.add(id); settledRevealIdsRef.current.add(id);
@@ -895,9 +957,8 @@ function MasonryGallery({
return result; return result;
}, [columnCount, images]); }, [columnCount, images]);
const synchronizedRowCount = columnCount <= 2 // Masonry row indices drift apart vertically, so only coordinate the first row.
? Math.max(0, ...columns.map((column) => column.length)) const synchronizedRowCount = columnCount <= 2 && images.length > 0 ? 1 : 0;
: 0;
const synchronizedRowMembers = useMemo( const synchronizedRowMembers = useMemo(
() => Array.from({ length: synchronizedRowCount }, (_, rowIndex) => ( () => Array.from({ length: synchronizedRowCount }, (_, rowIndex) => (
columns columns
@@ -925,13 +986,12 @@ function MasonryGallery({
<ImageCard <ImageCard
key={image.id} key={image.id}
image={image} image={image}
index={index}
eager={rowIndex < eagerRowCount} eager={rowIndex < eagerRowCount}
highPriority={rowIndex < highPriorityRowCount} highPriority={index === 0}
revealReady={!synchronizeReveal || readyRevealRows.has(rowIndex)} revealReady={!synchronizeReveal || readyRevealRows.has(rowIndex)}
revealGroup={synchronizeReveal ? rowIndex : undefined} revealGroup={synchronizeReveal ? rowIndex : undefined}
nameVisible={activeNameId === image.id} nameVisible={activeNameId === image.id}
onNameTrigger={revealName} onNameTouch={showName}
onSettled={synchronizeReveal ? markRevealImageSettled : undefined} onSettled={synchronizeReveal ? markRevealImageSettled : undefined}
/> />
); );
@@ -944,31 +1004,41 @@ function MasonryGallery({
const ImageCard = memo(function ImageCard({ const ImageCard = memo(function ImageCard({
image, image,
index,
eager, eager,
highPriority, highPriority,
revealReady, revealReady,
revealGroup, revealGroup,
nameVisible, nameVisible,
onNameTrigger, onNameTouch,
onSettled, onSettled,
}: { }: {
image: GalleryImage; image: GalleryImage;
index: number;
eager: boolean; eager: boolean;
highPriority: boolean; highPriority: boolean;
revealReady: boolean; revealReady: boolean;
revealGroup?: number; revealGroup?: number;
nameVisible: boolean; nameVisible: boolean;
onNameTrigger: (id: string) => void; onNameTouch: (id: string) => void;
onSettled?: (rowIndex: number, id: string) => void; onSettled?: (rowIndex: number, id: string) => void;
}) { }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [loadRequested, setLoadRequested] = useState(eager);
const cardRef = useRef<HTMLElement>(null);
const retryTimerRef = useRef(0); const retryTimerRef = useRef(0);
useEffect(() => () => window.clearTimeout(retryTimerRef.current), []); 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 = () => { const handleError = () => {
setLoaded(false); setLoaded(false);
@@ -984,12 +1054,13 @@ const ImageCard = memo(function ImageCard({
}, delay); }, delay);
}; };
const thumbnailUrl = attempt const thumbnailUrl = `/api/images/${encodeURIComponent(image.id)}/thumbnail`;
? `${image.thumbnailUrl}?retry=${attempt}` const retryQuery = attempt ? `retry=${attempt}` : "";
: image.thumbnailUrl; const imageUrl = retryQuery ? `${thumbnailUrl}?${retryQuery}` : thumbnailUrl;
const revealed = loaded && revealReady; const revealed = loaded && revealReady;
return ( return (
<figure <figure
ref={cardRef}
className="image-card" className="image-card"
title={image.name} title={image.name}
data-image-id={image.id} data-image-id={image.id}
@@ -997,19 +1068,20 @@ const ImageCard = memo(function ImageCard({
data-loaded={revealed} data-loaded={revealed}
data-failed={failed} data-failed={failed}
data-reveal-ready={revealReady} data-reveal-ready={revealReady}
data-eager={eager}
data-high-priority={highPriority}
style={{ style={{
aspectRatio: `${image.width} / ${image.height}`, aspectRatio: `${image.width} / ${image.height}`,
"--card-order": Math.min(index, 12),
} as CSSProperties} } as CSSProperties}
onPointerUp={(event) => { onPointerDown={(event) => {
if (event.pointerType !== "mouse") onNameTrigger(image.id); if (event.pointerType !== "mouse") onNameTouch(image.id);
}} }}
> >
{!failed ? ( {!failed && loadRequested ? (
<img <img
src={thumbnailUrl} src={imageUrl}
alt={image.name} alt={image.name}
loading={eager ? "eager" : "lazy"} loading="eager"
decoding="async" decoding="async"
fetchPriority={highPriority ? "high" : "auto"} fetchPriority={highPriority ? "high" : "auto"}
className={revealed ? "loaded" : ""} className={revealed ? "loaded" : ""}
@@ -1019,11 +1091,11 @@ const ImageCard = memo(function ImageCard({
}} }}
onError={handleError} onError={handleError}
/> />
) : ( ) : failed ? (
<span className="image-fallback" role="img" aria-label={`${image.name} 加载失败`}> <span className="image-fallback" role="img" aria-label={`${image.name} 加载失败`}>
<ImageIcon size={24} /> <ImageIcon size={24} />
</span> </span>
)} ) : null}
<span className="image-name">{image.name}</span> <span className="image-name">{image.name}</span>
</figure> </figure>
); );
+4 -7
View File
@@ -1,13 +1,10 @@
import { import type { ComponentChildren } from "preact";
type ReactNode, import { useEffect, useRef } from "preact/hooks";
useEffect,
useRef,
} from "react";
type ToolbarPopoverProps = { type ToolbarPopoverProps = {
children: ReactNode; children: ComponentChildren;
closeLabel: string; closeLabel: string;
icon: ReactNode; icon: ComponentChildren;
id: string; id: string;
open: boolean; open: boolean;
openLabel: string; openLabel: string;
+17 -1
View File
@@ -1,4 +1,5 @@
import type { import type {
BootstrapData,
GallerySummary, GallerySummary,
ImagesPage, ImagesPage,
SortMode, SortMode,
@@ -24,7 +25,7 @@ async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
cache: "no-store", cache: "no-cache",
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
signal: controller.signal, signal: controller.signal,
}); });
@@ -49,6 +50,21 @@ async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
} }
} }
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<GallerySummary> { export function getGallery(signal?: AbortSignal): Promise<GallerySummary> {
return getJson<GallerySummary>("/api/gallery", signal); return getJson<GallerySummary>("/api/gallery", signal);
} }
+114
View File
@@ -0,0 +1,114 @@
import type { ComponentChildren, JSX } from "preact";
// Minimal Lucide icon subset, distributed under the ISC license.
type IconProps = Omit<JSX.SVGAttributes<SVGSVGElement>, "size"> & {
size?: number | string;
strokeWidth?: number | string;
};
function Icon({
children,
size = 24,
strokeWidth = 2,
...props
}: IconProps & { children: ComponentChildren }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={props["aria-label"] ? undefined : true}
{...props}
>
{children}
</svg>
);
}
export function Check(props: IconProps) {
return <Icon {...props}><path d="M20 6 9 17l-5-5" /></Icon>;
}
export function Dices(props: IconProps) {
return (
<Icon {...props}>
<rect width="12" height="12" x="2" y="10" rx="2" ry="2" />
<path d="m17.92 14 3.5-3.5a2.24 2.24 0 0 0 0-3l-5-4.92a2.24 2.24 0 0 0-3 0L10 6" />
<path d="M6 18h.01" /><path d="M10 14h.01" />
<path d="M15 6h.01" /><path d="M18 9h.01" />
</Icon>
);
}
export function Folder(props: IconProps) {
return <Icon {...props}><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" /></Icon>;
}
export function ImageIcon(props: IconProps) {
return (
<Icon {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</Icon>
);
}
export function Images(props: IconProps) {
return (
<Icon {...props}>
<path d="m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16" />
<path d="M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2" />
<circle cx="13" cy="7" r="1" fill="currentColor" />
<rect x="8" y="2" width="14" height="14" rx="2" />
</Icon>
);
}
export function LoaderCircle(props: IconProps) {
return <Icon {...props}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></Icon>;
}
export function PanelLeftClose(props: IconProps) {
return (
<Icon {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" /><path d="m16 15-3-3 3-3" />
</Icon>
);
}
export function PanelLeftOpen(props: IconProps) {
return (
<Icon {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" /><path d="m14 9 3 3-3 3" />
</Icon>
);
}
export function RefreshCw(props: IconProps) {
return (
<Icon {...props}>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</Icon>
);
}
export function Search(props: IconProps) {
return <Icon {...props}><path d="m21 21-4.34-4.34" /><circle cx="11" cy="11" r="8" /></Icon>;
}
export function X(props: IconProps) {
return <Icon {...props}><path d="M18 6 6 18" /><path d="m6 6 12 12" /></Icon>;
}
+2 -7
View File
@@ -1,13 +1,8 @@
import { StrictMode } from "react"; import { render } from "preact";
import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (!root) throw new Error("Missing #root application container"); if (!root) throw new Error("Missing #root application container");
createRoot(root).render( render(<App />, root);
<StrictMode>
<App />
</StrictMode>,
);
+11 -13
View File
@@ -3,7 +3,7 @@
background: #f5f7f6; background: #f5f7f6;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: auto;
--topbar-height: 62px; --topbar-height: 62px;
--sidebar-width: 238px; --sidebar-width: 238px;
--sidebar-rail-width: 0px; --sidebar-rail-width: 0px;
@@ -746,7 +746,6 @@ select:focus-visible {
grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); grid-template-columns: repeat(var(--columns), minmax(0, 1fr));
align-items: start; align-items: start;
gap: 6px; gap: 6px;
animation: gallery-in var(--motion-medium) ease-out both;
} }
.masonry-column { .masonry-column {
@@ -768,7 +767,6 @@ select:focus-visible {
border: 1px solid rgba(26, 35, 31, 0.08); border: 1px solid rgba(26, 35, 31, 0.08);
border-radius: 5px; border-radius: 5px;
box-shadow: 0 1px 1px rgba(21, 31, 27, 0.05); 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: transition:
border-color var(--motion-medium) ease, border-color var(--motion-medium) ease,
box-shadow var(--motion-medium) var(--ease-standard), box-shadow var(--motion-medium) var(--ease-standard),
@@ -786,6 +784,10 @@ select:focus-visible {
background-size: 220% 100%, 100% 100%; background-size: 220% 100%, 100% 100%;
opacity: 1; opacity: 1;
transition: opacity var(--motion-medium) ease; transition: opacity var(--motion-medium) ease;
animation: none;
}
.image-card[data-eager="true"]::before {
animation: card-shimmer 1.35s ease-in-out infinite; animation: card-shimmer 1.35s ease-in-out infinite;
} }
@@ -815,6 +817,12 @@ select:focus-visible {
transform: scale(1); transform: scale(1);
} }
.image-card[data-high-priority="true"] img {
opacity: 1;
transform: scale(1);
transition: none;
}
.image-name { .image-name {
position: absolute; position: absolute;
inset: auto 4px 4px; inset: auto 4px 4px;
@@ -851,16 +859,6 @@ select:focus-visible {
animation: feedback-in var(--motion-medium) ease-out both; 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 { @keyframes card-shimmer {
from { background-position: 130% 0, 0 0; } from { background-position: 130% 0, 0 0; }
to { background-position: -120% 0, 0 0; } to { background-position: -120% 0, 0 0; }
+6 -5
View File
@@ -13,13 +13,8 @@ export interface GallerySummary {
export interface GalleryImage { export interface GalleryImage {
id: string; id: string;
name: string; name: string;
album: string;
relativePath: string;
width: number; width: number;
height: number; height: number;
size: number;
modifiedMs: number;
thumbnailUrl: string;
} }
export interface ImagesPage { export interface ImagesPage {
@@ -40,4 +35,10 @@ export interface ThumbnailStatus {
backgroundComplete: boolean; backgroundComplete: boolean;
} }
export interface BootstrapData {
summary: GallerySummary;
status: ThumbnailStatus;
images: ImagesPage;
}
export type SortMode = "name-asc" | "name-desc" | "newest" | "explore"; export type SortMode = "name-asc" | "name-desc" | "newest" | "explore";
+1 -1
View File
@@ -16,9 +16,9 @@
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "preact",
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true "noUnusedParameters": true
}, },
"include": ["src"] "include": ["src"]
} }
-3
View File
@@ -1,8 +1,6 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()],
build: { build: {
target: "es2022", target: "es2022",
outDir: "dist", outDir: "dist",
@@ -18,4 +16,3 @@ export default defineConfig({
}, },
}, },
}); });
+3
View File
@@ -17,6 +17,7 @@ pub struct ImageRecord {
pub id: String, pub id: String,
pub path: PathBuf, pub path: PathBuf,
pub relative_path: String, pub relative_path: String,
pub(crate) search_key: String,
pub name: String, pub name: String,
pub album: String, pub album: String,
pub width: u32, pub width: u32,
@@ -130,11 +131,13 @@ pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result<Gall
.unwrap_or(&relative_path) .unwrap_or(&relative_path)
.to_owned(); .to_owned();
let id = image_id(&relative_path, size, modified_ns); let id = image_id(&relative_path, size, modified_ns);
let search_key = relative_path.to_lowercase();
images.push(Arc::new(ImageRecord { images.push(Arc::new(ImageRecord {
id, id,
path, path,
relative_path, relative_path,
search_key,
name, name,
album, album,
width, width,
+23 -2
View File
@@ -574,11 +574,11 @@ fn cleanup_cache(root: &Path, valid_ids: &HashSet<String>) {
if path.extension().and_then(|ext| ext.to_str()) != Some("webp") { if path.extension().and_then(|ext| ext.to_str()) != Some("webp") {
return; return;
} }
let id = path let stem = path
.file_stem() .file_stem()
.and_then(|stem| stem.to_str()) .and_then(|stem| stem.to_str())
.unwrap_or_default(); .unwrap_or_default();
if !valid_ids.contains(id) if !valid_ids.contains(stem)
&& let Err(error) = fs::remove_file(path) && let Err(error) = fs::remove_file(path)
{ {
warn!(path = %path.display(), %error, "cannot remove stale thumbnail"); warn!(path = %path.display(), %error, "cannot remove stale thumbnail");
@@ -627,6 +627,27 @@ mod tests {
assert_eq!(generated.dimensions(), (720, 360)); 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] #[test]
fn queue_promotes_without_duplicate_jobs() { fn queue_promotes_without_duplicate_jobs() {
let queue = JobQueue::default(); let queue = JobQueue::default();
+365 -72
View File
@@ -4,14 +4,18 @@ use axum::{
Json, Router, Json, Router,
body::Body, body::Body,
extract::{Path, Query, Request, State}, extract::{Path, Query, Request, State},
http::{HeaderValue, Method, StatusCode, header}, http::{HeaderMap, HeaderValue, Method, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::get, routing::get,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tower::ServiceExt; 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 tracing::error;
use crate::{ use crate::{
@@ -31,6 +35,8 @@ const APP_CSS: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/frontend/dist/assets/app.css" "/frontend/dist/assets/app.css"
)); ));
const ASSET_VERSION: &str = env!("PIXHELF_ASSET_VERSION");
const BOOTSTRAP_PAGE_SIZE: usize = 60;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -50,7 +56,7 @@ struct ImagesQuery {
limit: Option<usize>, limit: Option<usize>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Clone, Copy, Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
enum ImageSort { enum ImageSort {
#[default] #[default]
@@ -83,13 +89,8 @@ struct ImagesResponse {
struct ImageView { struct ImageView {
id: String, id: String,
name: String, name: String,
album: String,
relative_path: String,
width: u32, width: u32,
height: u32, height: u32,
size: u64,
modified_ms: u64,
thumbnail_url: String,
} }
impl From<&ImageRecord> for ImageView { impl From<&ImageRecord> for ImageView {
@@ -97,17 +98,19 @@ impl From<&ImageRecord> for ImageView {
Self { Self {
id: record.id.clone(), id: record.id.clone(),
name: record.name.clone(), name: record.name.clone(),
album: record.album.clone(),
relative_path: record.relative_path.clone(),
width: record.width, width: record.width,
height: record.height, 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 { pub fn router(state: AppState) -> Router {
Router::new() Router::new()
.route("/api/health", get(health)) .route("/api/health", get(health))
@@ -115,10 +118,10 @@ pub fn router(state: AppState) -> Router {
.route("/api/images", get(images)) .route("/api/images", get(images))
.route("/api/status", get(thumbnail_status)) .route("/api/status", get(thumbnail_status))
.route("/api/images/{id}/thumbnail", get(thumbnail)) .route("/api/images/{id}/thumbnail", get(thumbnail))
.route("/assets/app.js", get(app_js)) .route("/assets/{version}/app.js", get(app_js))
.route("/assets/app.css", get(app_css)) .route("/assets/{version}/app.css", get(app_css))
.fallback(frontend) .fallback(frontend)
.layer(CompressionLayer::new()) .layer(CompressionLayer::new().quality(CompressionLevel::Precise(5)))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
@@ -127,20 +130,43 @@ async fn health() -> &'static str {
"ok" "ok"
} }
async fn gallery_summary(State(state): State<AppState>) -> Json<GallerySummary> { async fn gallery_summary(State(state): State<AppState>, headers: HeaderMap) -> Response {
let index = state.index.read().await; 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(), total: index.images.len(),
albums: index.albums.clone(), albums: index.albums.clone(),
revision: index.revision.clone(), revision: index.revision.clone(),
}) }
} }
async fn images( async fn images(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<ImagesQuery>, Query(query): Query<ImagesQuery>,
) -> Json<ImagesResponse> { headers: HeaderMap,
let records = state.index.read().await.images.clone(); ) -> 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 let search = query
.search .search
.as_deref() .as_deref()
@@ -148,52 +174,137 @@ async fn images(
.filter(|search| !search.is_empty()) .filter(|search| !search.is_empty())
.map(str::to_lowercase); .map(str::to_lowercase);
let album = query.album.as_deref().filter(|album| !album.is_empty()); let album = query.album.as_deref().filter(|album| !album.is_empty());
let requested_offset = query.offset.unwrap_or(0);
let mut matches: Vec<Arc<ImageRecord>> = records let limit = query.limit.unwrap_or(60).clamp(1, 200);
.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();
match query.sort { match query.sort {
ImageSort::NameAsc => {} ImageSort::NameAsc if album.is_none() && search.is_none() => {
ImageSort::NameDesc => matches.reverse(), page_from_unfiltered_index(index, false, requested_offset, limit)
ImageSort::Newest => matches.sort_by_key(|record| Reverse(record.modified_ms)), }
ImageSort::Explore => { ImageSort::NameDesc if album.is_none() && search.is_none() => {
let seed_key = page_from_unfiltered_index(index, true, requested_offset, limit)
*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 => 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(); fn page_from_unfiltered_index(
let offset = query.offset.unwrap_or(0).min(total); index: &GalleryIndex,
let limit = query.limit.unwrap_or(60).clamp(1, 200); reverse: bool,
let end = offset.saturating_add(limit).min(total); requested_offset: usize,
let items = matches[offset..end] limit: usize,
.iter() ) -> ImagesResponse {
let total = index.images.len();
let offset = requested_offset.min(total);
let records: Box<dyn Iterator<Item = &Arc<ImageRecord>> + '_> = 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())) .map(|record| ImageView::from(record.as_ref()))
.collect(); .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<Item = &'a ImageRecord>,
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<ImageView>,
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, items,
total, total,
offset, offset,
limit, limit,
next_offset: (end < total).then_some(end), next_offset: (end < total).then_some(end),
}) }
} }
fn exploration_rank(seed_key: &[u8; 32], image_id: &str) -> [u8; 32] { fn exploration_rank(seed_key: &[u8; 32], image_id: &str) -> [u8; 32] {
*blake3::keyed_hash(seed_key, image_id.as_bytes()).as_bytes() *blake3::keyed_hash(seed_key, image_id.as_bytes()).as_bytes()
} }
async fn thumbnail_status(State(state): State<AppState>) -> Json<ThumbnailStatus> { async fn thumbnail_status(State(state): State<AppState>) -> Response {
Json(state.thumbnails.status()) 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( async fn thumbnail(
@@ -265,37 +376,166 @@ fn add_cache_headers(
Ok(()) Ok(())
} }
async fn app_js() -> Response { fn revision_etag(kind: &str, revision: &str) -> String {
embedded(APP_JS, "text/javascript; charset=utf-8") format!("\"{kind}-{revision}\"")
} }
async fn app_css() -> Response { fn images_etag(revision: &str, query: &ImagesQuery) -> String {
embedded(APP_CSS, "text/css; charset=utf-8") 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<String>, headers: HeaderMap) -> Response {
embedded_asset(
&version,
APP_JS,
"text/javascript; charset=utf-8",
"js",
&headers,
)
}
async fn app_css(Path(version): Path<String>, 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<AppState>, request: Request) -> Response {
if request.uri().path() != "/" { if request.uri().path() != "/" {
return StatusCode::NOT_FOUND.into_response(); return StatusCode::NOT_FOUND.into_response();
} }
if request.method() != Method::GET && request.method() != Method::HEAD { if request.method() != Method::GET && request.method() != Method::HEAD {
return StatusCode::METHOD_NOT_ALLOWED.into_response(); 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#"<link rel="preload" as="image" href="{url}" fetchpriority="high" />"#)
});
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!(
"<style>{}</style>",
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#"<link rel="stylesheet" crossorigin href="/assets/app.css">"#,
&inline_styles,
)
.replace("/assets/app.js", &format!("/assets/{ASSET_VERSION}/app.js"))
.replace(
"/assets/app.css",
&format!("/assets/{ASSET_VERSION}/app.css"),
)
.replace("<!--PIXHELF_IMAGE_PRELOAD-->", &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 { fn escape_script_json(json: String) -> String {
let mut response = Response::new(Body::from(content)); json.replace('&', "\\u0026")
response .replace('<', "\\u003c")
.headers_mut() .replace('>', "\\u003e")
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); .replace('\u{2028}', "\\u2028")
response .replace('\u{2029}', "\\u2029")
.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
} }
#[cfg(test)] #[cfg(test)]
@@ -319,11 +559,12 @@ mod tests {
} }
#[test] #[test]
fn image_view_uses_versioned_id_urls() { fn image_view_keeps_the_payload_compact() {
let record = ImageRecord { let record = ImageRecord {
id: "abc".into(), id: "abc".into(),
path: "photo.jpg".into(), path: "photo.jpg".into(),
relative_path: "album/photo.jpg".into(), relative_path: "album/photo.jpg".into(),
search_key: "album/photo.jpg".into(),
name: "photo.jpg".into(), name: "photo.jpg".into(),
album: "album".into(), album: "album".into(),
width: 10, width: 10,
@@ -333,7 +574,11 @@ mod tests {
modified_ns: 40, modified_ns: 40,
}; };
let view = ImageView::from(&record); 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] #[test]
@@ -385,9 +630,57 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn fixed_asset_names_are_revalidated() { async fn versioned_assets_are_immutable() {
let response = request("/assets/app.js").await; let response = request(&format!("/assets/{ASSET_VERSION}/app.js")).await;
assert_eq!(response.status(), StatusCode::OK); 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("<style>:root"));
assert!(!html.contains("/assets/app.css"));
assert!(html.contains(r#""summary":{"total":0"#));
assert!(!html.contains("__PIXHELF_BOOTSTRAP__"));
}
#[test]
fn bootstrap_json_cannot_close_its_script_element() {
let escaped = escape_script_json(r#"{"name":"</script><script>"}"#.to_owned());
assert!(!escaped.contains('<'));
assert!(escaped.contains(r#"\u003c/script\u003e"#));
}
#[tokio::test]
async fn gallery_etag_supports_conditional_requests() {
let response = empty_router()
.oneshot(
Request::builder()
.uri("/api/gallery")
.header(header::IF_NONE_MATCH, "\"gallery-\"")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-cache"
);
} }
} }