Release v0.1.3
添加查看器前的最后加固
This commit is contained in:
1 parent
d2d7c23cbd
commit
e646d6762a
20 files changed
+849
-891
No files matched your search
Generated
+39
-1
@@ -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",
|
||||
|
||||
+3
-2
@@ -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"
|
||||
|
||||
@@ -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` 下的版本化目录,原图不会被修改。缓存目录必须位于图库目录之外。
|
||||
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -8,11 +8,13 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#f4f5f4" />
|
||||
<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" />
|
||||
<title>Pixhelf</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script id="pixhelf-bootstrap" type="application/json">__PIXHELF_BOOTSTRAP__</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+16
-709
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+121
-49
@@ -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<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() {
|
||||
useVisualViewportTop();
|
||||
const [summary, setSummary] = useState<GallerySummary | null>(null);
|
||||
const [status, setStatus] = useState<ThumbnailStatus | null>(null);
|
||||
const [imagePage, setImagePage] = useState<ImagePageState>(EMPTY_IMAGE_PAGE);
|
||||
const [summary, setSummary] = useState<GallerySummary | null>(
|
||||
INITIAL_BOOTSTRAP?.summary ?? null,
|
||||
);
|
||||
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 [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<string | null>(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<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 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<string>());
|
||||
const revealRowMembersRef = useRef<string[][]>([]);
|
||||
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({
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
image={image}
|
||||
index={index}
|
||||
eager={rowIndex < eagerRowCount}
|
||||
highPriority={rowIndex < highPriorityRowCount}
|
||||
highPriority={index === 0}
|
||||
revealReady={!synchronizeReveal || readyRevealRows.has(rowIndex)}
|
||||
revealGroup={synchronizeReveal ? rowIndex : undefined}
|
||||
nameVisible={activeNameId === image.id}
|
||||
onNameTrigger={revealName}
|
||||
onNameTouch={showName}
|
||||
onSettled={synchronizeReveal ? markRevealImageSettled : undefined}
|
||||
/>
|
||||
);
|
||||
@@ -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<HTMLElement>(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 (
|
||||
<figure
|
||||
ref={cardRef}
|
||||
className="image-card"
|
||||
title={image.name}
|
||||
data-image-id={image.id}
|
||||
@@ -997,19 +1068,20 @@ const ImageCard = memo(function ImageCard({
|
||||
data-loaded={revealed}
|
||||
data-failed={failed}
|
||||
data-reveal-ready={revealReady}
|
||||
data-eager={eager}
|
||||
data-high-priority={highPriority}
|
||||
style={{
|
||||
aspectRatio: `${image.width} / ${image.height}`,
|
||||
"--card-order": Math.min(index, 12),
|
||||
} as CSSProperties}
|
||||
onPointerUp={(event) => {
|
||||
if (event.pointerType !== "mouse") onNameTrigger(image.id);
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType !== "mouse") onNameTouch(image.id);
|
||||
}}
|
||||
>
|
||||
{!failed ? (
|
||||
{!failed && loadRequested ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
src={imageUrl}
|
||||
alt={image.name}
|
||||
loading={eager ? "eager" : "lazy"}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchPriority={highPriority ? "high" : "auto"}
|
||||
className={revealed ? "loaded" : ""}
|
||||
@@ -1019,11 +1091,11 @@ const ImageCard = memo(function ImageCard({
|
||||
}}
|
||||
onError={handleError}
|
||||
/>
|
||||
) : (
|
||||
) : failed ? (
|
||||
<span className="image-fallback" role="img" aria-label={`${image.name} 加载失败`}>
|
||||
<ImageIcon size={24} />
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<span className="image-name">{image.name}</span>
|
||||
</figure>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+17
-1
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BootstrapData,
|
||||
GallerySummary,
|
||||
ImagesPage,
|
||||
SortMode,
|
||||
@@ -24,7 +25,7 @@ async function getJson<T>(url: string, signal?: AbortSignal): Promise<T> {
|
||||
|
||||
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<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> {
|
||||
return getJson<GallerySummary>("/api/gallery", signal);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
render(<App />, root);
|
||||
+11
-13
@@ -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; }
|
||||
|
||||
@@ -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";
|
||||
@@ -16,9 +16,9 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Gall
|
||||
.unwrap_or(&relative_path)
|
||||
.to_owned();
|
||||
let id = image_id(&relative_path, size, modified_ns);
|
||||
let search_key = relative_path.to_lowercase();
|
||||
|
||||
images.push(Arc::new(ImageRecord {
|
||||
id,
|
||||
path,
|
||||
relative_path,
|
||||
search_key,
|
||||
name,
|
||||
album,
|
||||
width,
|
||||
|
||||
+23
-2
@@ -574,11 +574,11 @@ fn cleanup_cache(root: &Path, valid_ids: &HashSet<String>) {
|
||||
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();
|
||||
|
||||
+365
-72
@@ -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<usize>,
|
||||
}
|
||||
|
||||
#[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<AppState>) -> Json<GallerySummary> {
|
||||
async fn gallery_summary(State(state): State<AppState>, 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<AppState>,
|
||||
Query(query): Query<ImagesQuery>,
|
||||
) -> Json<ImagesResponse> {
|
||||
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<Arc<ImageRecord>> = 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<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()))
|
||||
.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,
|
||||
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<AppState>) -> Json<ThumbnailStatus> {
|
||||
Json(state.thumbnails.status())
|
||||
async fn thumbnail_status(State(state): State<AppState>) -> 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<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() != "/" {
|
||||
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#"<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 {
|
||||
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("<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user