From 02516abfd94eb882b8de4cc57d3d6fa91b57c2ad Mon Sep 17 00:00:00 2001 From: adminroot Date: Fri, 21 Aug 2026 19:23:22 +0000 Subject: [PATCH] Initial import --- .gitignore | 7 + Cargo.lock | 1110 ++++++++++++++ Cargo.toml | 29 + README.md | 41 + build.rs | 29 + frontend/index.html | 14 + frontend/package-lock.json | 1907 ++++++++++++++++++++++++ frontend/package.json | 25 + frontend/scripts/visual-check.mjs | 286 ++++ frontend/src/App.tsx | 627 ++++++++ frontend/src/ImageViewer.tsx | 855 +++++++++++ frontend/src/api.ts | 43 + frontend/src/main.tsx | 11 + frontend/src/styles.css | 1153 ++++++++++++++ frontend/src/types.ts | 44 + frontend/src/viewer/useViewerEngine.ts | 826 ++++++++++ frontend/tsconfig.app.json | 24 + frontend/tsconfig.json | 5 + frontend/vite.config.ts | 21 + src/config.rs | 125 ++ src/gallery.rs | 259 ++++ src/main.rs | 133 ++ src/thumbs.rs | 757 ++++++++++ src/web.rs | 351 +++++ 24 files changed, 8682 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build.rs create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/scripts/visual-check.mjs create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/ImageViewer.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/viewer/useViewerEngine.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 src/config.rs create mode 100644 src/gallery.rs create mode 100644 src/main.rs create mode 100644 src/thumbs.rs create mode 100644 src/web.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbba87c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/pic/ +/.pixhelf-cache/ +/target/ +/frontend/node_modules/ +/frontend/dist/ +*.log + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..145f677 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1110 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pixhelf" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "blake3", + "image", + "mime_guess", + "natord", + "serde", + "tempfile", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "walkdir", + "webp", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" +dependencies = [ + "image", + "libwebp-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..be89e25 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pixhelf" +version = "0.1.0" +edition = "2024" +build = "build.rs" + +[dependencies] +anyhow = "1.0.104" +axum = { version = "0.8.9", features = ["json", "query", "tokio"] } +blake3 = "1.8.7" +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } +mime_guess = "2.0.5" +natord = "1.0.9" +serde = { version = "1.0.229", features = ["derive"] } +tokio = { version = "1.53.1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } +tower = { version = "0.5.3", features = ["util"] } +tower-http = { version = "0.6.11", features = ["compression-gzip", "fs", "set-header", "trace"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +walkdir = "2.5.0" +webp = "0.3.1" + +[dev-dependencies] +tempfile = "3.27.0" + +[profile.release] +lto = "thin" +strip = "symbols" + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e01249e --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Pixhelf + +基于 Axum 和 React 的自托管图片画廊。React 生产资源会嵌入 Rust 可执行文件,运行时只需要一个程序文件和外部图库目录。 + +## 运行 + +默认从当前工作目录的 `./pic` 读取图片,并监听 `0.0.0.0:3002`: + +```bash +./target/release/pixhelf +``` + +首次启动会先生成首批 60 张、最长边 720px 的 WebP 缩略图,然后开放 HTTP 服务。其余缩略图由后台任务持续处理;打开图片时,查看器会按需生成独立的最长边 2560px WebP 预览。新增、修改或删除图片会在定期扫描后自动同步。 + +缩略图和查看器预览缓存在 `./.pixhelf-cache/thumbnails` 下的版本化目录,原图不会被修改。 + +## 构建 + +需要当前稳定版 Rust、Node.js 20+ 和 npm: + +```bash +cd frontend +npm ci +cd .. +cargo build --release +``` + +`cargo build` 会自动执行 React 生产构建,并将结果嵌入 `target/release/pixhelf`。 + +## 参数 + +```text +--gallery-dir PATH 图库目录,默认 ./pic +--cache-dir PATH 缩略图和查看器预览缓存,默认 ./.pixhelf-cache/thumbnails +--listen HOST:PORT 监听地址,默认 0.0.0.0:3002 +--initial-batch N 启动前生成数量,默认 60 +--workers N 后台压缩并发数,默认按 CPU 自动选择 2-4 +--scan-interval SEC 图库重新扫描间隔,默认 10 秒 +``` + +支持 JPEG、PNG 和 WebP 文件。其他文件不会进入图库索引。 diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f7ee41a --- /dev/null +++ b/build.rs @@ -0,0 +1,29 @@ +use std::{env, path::PathBuf, process::Command}; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory")); + let frontend_dir = manifest_dir.join("frontend"); + + for path in [ + "frontend/index.html", + "frontend/package.json", + "frontend/package-lock.json", + "frontend/tsconfig.json", + "frontend/vite.config.ts", + "frontend/src", + ] { + println!("cargo:rerun-if-changed={path}"); + } + + if env::var_os("PIXHELF_SKIP_FRONTEND_BUILD").is_some() { + return; + } + + let status = Command::new("npm") + .args(["run", "build", "--silent"]) + .current_dir(frontend_dir) + .status() + .expect("failed to run the frontend build; install Node.js and run `npm ci` in frontend/"); + + assert!(status.success(), "frontend build failed"); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ed85ebf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Pixhelf + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..fb4ba5a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1907 @@ +{ + "name": "pixhelf-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pixhelf-frontend", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.539.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.7.0", + "playwright-core": "^1.62.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } + }, + "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", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "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", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "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", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "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", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "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", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "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": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "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", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "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", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "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==", + "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" + }, + "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" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "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", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "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", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d722662 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "pixhelf-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "visual-check": "node scripts/visual-check.mjs" + }, + "dependencies": { + "lucide-react": "^0.539.0", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.7.0", + "playwright-core": "^1.62.1", + "typescript": "^5.9.2", + "vite": "^7.1.3" + } +} diff --git a/frontend/scripts/visual-check.mjs b/frontend/scripts/visual-check.mjs new file mode 100644 index 0000000..26a5084 --- /dev/null +++ b/frontend/scripts/visual-check.mjs @@ -0,0 +1,286 @@ +import { chromium } from "playwright-core"; + +const executablePath = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ?? + "/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome"; +const baseUrl = process.env.PIXHELF_URL ?? "http://127.0.0.1:3002"; + +const browser = await chromium.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); + +const results = []; + +async function dispatchTouch(cdp, type, points) { + await cdp.send("Input.dispatchTouchEvent", { + type, + touchPoints: points.map((point) => ({ + id: point.id, + x: point.x, + y: point.y, + radiusX: 5, + radiusY: 5, + force: 1, + })), + }); +} + +async function touchTap(cdp, point, pointerId = 1) { + await dispatchTouch(cdp, "touchStart", [{ ...point, id: pointerId }]); + await dispatchTouch(cdp, "touchEnd", []); +} + +async function touchDrag(cdp, from, to, steps = 10, pointerId = 1) { + await dispatchTouch(cdp, "touchStart", [{ ...from, id: pointerId }]); + for (let step = 1; step <= steps; step += 1) { + await dispatchTouch(cdp, "touchMove", [{ + id: pointerId, + x: from.x + (to.x - from.x) * (step / steps), + y: from.y + (to.y - from.y) * (step / steps), + }]); + } + await dispatchTouch(cdp, "touchEnd", []); +} + +async function touchPinch(cdp, center, fromDistance, toDistance, steps = 10) { + const firstStart = { id: 1, x: center.x - fromDistance / 2, y: center.y }; + const secondStart = { id: 2, x: center.x + fromDistance / 2, y: center.y }; + await dispatchTouch(cdp, "touchStart", [firstStart, secondStart]); + for (let step = 1; step <= steps; step += 1) { + const distance = fromDistance + (toDistance - fromDistance) * (step / steps); + await dispatchTouch(cdp, "touchMove", [ + { id: 1, x: center.x - distance / 2, y: center.y }, + { id: 2, x: center.x + distance / 2, y: center.y }, + ]); + } + await dispatchTouch(cdp, "touchEnd", []); +} + +try { + for (const target of [ + { name: "desktop", width: 1440, height: 1000 }, + { name: "mobile", width: 390, height: 844 }, + ]) { + const page = await browser.newPage({ + viewport: { width: target.width, height: target.height }, + deviceScaleFactor: target.name === "mobile" ? 3 : 1, + hasTouch: target.name === "mobile", + }); + const browserErrors = []; + page.on("console", (message) => { + if (message.type() === "error") browserErrors.push(message.text()); + }); + page.on("pageerror", (error) => browserErrors.push(error.message)); + + await page.goto(baseUrl, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForSelector("[data-image-id]", { timeout: 60_000 }); + await page.waitForFunction(() => { + const visible = [...document.querySelectorAll("[data-image-id] img")].filter((image) => { + const rect = image.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < innerHeight; + }); + return visible.length >= 8 && visible.every((image) => image.complete && image.naturalWidth > 0); + }, undefined, { timeout: 60_000 }); + await page.waitForTimeout(250); + await page.screenshot({ path: `/tmp/pixhelf-${target.name}.png` }); + + const layout = await page.evaluate(() => ({ + viewport: [innerWidth, innerHeight], + bodyWidth: document.documentElement.scrollWidth, + cards: document.querySelectorAll("[data-image-id]").length, + masonryColumns: document.querySelectorAll(".masonry-column").length, + loadedCards: document.querySelectorAll("[data-image-id] img.loaded").length, + brokenVisibleImages: [...document.images].filter((image) => { + const rect = image.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < innerHeight && image.naturalWidth === 0; + }).length, + horizontalOverflow: document.documentElement.scrollWidth > innerWidth + 1, + })); + + await page.locator("[data-image-id]").first().click(); + await page.waitForSelector(".image-viewer"); + await page.waitForFunction(() => { + const image = document.querySelector(".viewer-image-original"); + return image instanceof HTMLImageElement && + image.complete && + image.naturalWidth > 0 && + image.classList.contains("is-ready") && + Number(getComputedStyle(image).opacity) > 0.98; + }, undefined, { timeout: 60_000 }); + await page.waitForTimeout(250); + await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}.png` }); + + const viewerLayout = await page.evaluate(() => { + const viewer = document.querySelector(".image-viewer"); + const frame = document.querySelector(".viewer-image-frame"); + const image = document.querySelector(".viewer-image-original"); + const transform = document.querySelector(".viewer-transform"); + if (!viewer || !frame || !(image instanceof HTMLImageElement) || !transform) { + return { visible: false }; + } + const rect = frame.getBoundingClientRect(); + const imageStyle = getComputedStyle(image); + return { + visible: true, + frame: [rect.left, rect.top, rect.right, rect.bottom], + naturalSize: [image.naturalWidth, image.naturalHeight], + imageOpacity: imageStyle.opacity, + objectFit: imageStyle.objectFit, + noLegacyLightbox: document.querySelectorAll(".yarl__portal, .yarl__thumbnails_container").length === 0, + controlsVisible: viewer.getAttribute("data-controls-visible"), + transform: getComputedStyle(transform).transform, + }; + }); + + const frame = await page.locator(".viewer-canvas").boundingBox(); + if (!frame) throw new Error("viewer canvas is missing"); + const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 }; + const transform = page.locator(".viewer-transform"); + await page.mouse.dblclick(center.x + 48, center.y - 32, { delay: 70 }); + await page.waitForFunction(() => document.querySelector(".viewer-transform")?.getAttribute("data-zoomed") === "true"); + const doubleClickZoom = await transform.evaluate((element) => getComputedStyle(element).transform); + await page.waitForTimeout(700); + const zoomedLayout = await page.evaluate(() => { + const frame = document.querySelector(".viewer-image-frame"); + const transform = document.querySelector(".viewer-transform"); + if (!frame || !transform) return { frame: [0, 0, 0, 0], scale: 0, rasterScale: 0 }; + const rect = frame.getBoundingClientRect(); + return { + frame: [rect.left, rect.top, rect.width, rect.height], + scale: Number(transform.getAttribute("data-scale") ?? 0), + rasterScale: Number(transform.getAttribute("data-raster-scale") ?? transform.getAttribute("data-rasterScale") ?? 0), + }; + }); + await page.screenshot({ path: `/tmp/pixhelf-viewer-${target.name}-zoomed.png` }); + + await page.mouse.click(center.x, center.y); + await page.waitForTimeout(300); + const singleClickControls = await page.locator(".image-viewer").getAttribute("data-controls-visible"); + + await page.mouse.wheel(0, -360); + await page.waitForTimeout(350); + const wheelZoom = await transform.evaluate((element) => getComputedStyle(element).transform); + + const counterBefore = await page.locator(".viewer-counter").textContent(); + await page.keyboard.press("ArrowRight"); + await page.waitForFunction((previous) => document.querySelector(".viewer-counter")?.textContent !== previous, counterBefore); + const counterAfter = await page.locator(".viewer-counter").textContent(); + + let touchChecks = null; + if (target.name === "mobile") { + const cdp = await page.context().newCDPSession(page); + const swipeFrame = await page.locator(".viewer-canvas").boundingBox(); + if (!swipeFrame) throw new Error("mobile viewer canvas is missing"); + const centerPoint = { + x: swipeFrame.x + swipeFrame.width / 2, + y: swipeFrame.y + swipeFrame.height / 2, + }; + + await page.keyboard.press("0"); + await page.waitForTimeout(280); + const pinchBefore = await transform.getAttribute("data-scale"); + await touchPinch(cdp, centerPoint, 82, 210); + await page.waitForTimeout(300); + const pinchAfter = await transform.getAttribute("data-scale"); + await page.keyboard.press("0"); + await page.waitForTimeout(280); + + const touchCounterBefore = await page.locator(".viewer-counter").textContent(); + await touchDrag( + cdp, + { x: swipeFrame.x + swipeFrame.width * 0.78, y: centerPoint.y }, + { x: swipeFrame.x + swipeFrame.width * 0.2, y: centerPoint.y + 3 }, + ); + await page.waitForFunction( + (previous) => document.querySelector(".viewer-counter")?.textContent !== previous, + touchCounterBefore, + ); + const touchCounterAfter = await page.locator(".viewer-counter").textContent(); + + const controlsBeforeTap = await page.locator(".image-viewer").getAttribute("data-controls-visible"); + await touchTap(cdp, { x: 18, y: centerPoint.y }); + await page.waitForTimeout(330); + const viewerStillOpenAfterBlankTap = await page.locator(".image-viewer").count(); + const controlsAfterTap = await page.locator(".image-viewer").getAttribute("data-controls-visible"); + + await touchDrag( + cdp, + { x: centerPoint.x, y: swipeFrame.y + swipeFrame.height * 0.34 }, + { x: centerPoint.x + 3, y: swipeFrame.y + swipeFrame.height * 0.7 }, + ); + await page.waitForSelector(".image-viewer", { state: "detached" }); + + touchChecks = { + pinchBefore: Number(pinchBefore), + pinchAfter: Number(pinchAfter), + touchCounterBefore, + touchCounterAfter, + controlsBeforeTap, + controlsAfterTap, + viewerStillOpenAfterBlankTap, + dismissDetached: true, + }; + } else { + await page.keyboard.press("Escape"); + await page.waitForSelector(".image-viewer", { state: "detached" }); + } + + const result = { + name: target.name, + ...layout, + viewerLayout, + zoomedLayout, + doubleClickZoom, + wheelZoom, + singleClickControls, + counterBefore, + counterAfter, + touchChecks, + browserErrors, + }; + results.push(result); + console.log(JSON.stringify(result)); + + const expectedColumns = target.name === "desktop" ? 5 : 2; + const viewerContained = viewerLayout.visible && + viewerLayout.frame[0] >= -1 && + viewerLayout.frame[1] >= -1 && + viewerLayout.frame[2] <= target.width + 1 && + viewerLayout.frame[3] <= target.height + 1; + if ( + layout.horizontalOverflow || + layout.brokenVisibleImages || + layout.masonryColumns !== expectedColumns || + !viewerContained || + Number(viewerLayout.imageOpacity) < 0.98 || + viewerLayout.objectFit !== "contain" || + !viewerLayout.noLegacyLightbox || + !doubleClickZoom.includes("matrix") || + !wheelZoom.includes("matrix") || + zoomedLayout.scale <= 1.01 || + zoomedLayout.rasterScale <= 1.01 || + zoomedLayout.frame[2] <= (viewerLayout.frame[2] - viewerLayout.frame[0]) * 1.1 || + Math.abs(zoomedLayout.frame[2] / (viewerLayout.frame[2] - viewerLayout.frame[0]) - zoomedLayout.scale) > 0.08 || + singleClickControls !== "false" || + counterBefore === counterAfter || + (target.name === "mobile" && ( + !touchChecks || + touchChecks.pinchAfter <= touchChecks.pinchBefore + 0.2 || + touchChecks.touchCounterBefore === touchChecks.touchCounterAfter || + touchChecks.viewerStillOpenAfterBlankTap !== 1 || + touchChecks.controlsBeforeTap === touchChecks.controlsAfterTap || + !touchChecks.dismissDetached + )) || + browserErrors.length + ) { + throw new Error(`visual check failed: ${JSON.stringify(result)}`); + } + await page.close(); + } +} finally { + await browser.close(); +} + +console.log(JSON.stringify(results, null, 2)); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5463f34 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,627 @@ +import { + ArrowDownAZ, + Check, + Folder, + Image as ImageIcon, + Images, + LoaderCircle, + Menu, + PanelLeftClose, + RefreshCw, + Search, + X, +} from "lucide-react"; +import { + type CSSProperties, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { getGallery, getImages, getStatus } from "./api"; +import ImageViewer from "./ImageViewer"; +import type { + GalleryImage, + GallerySummary, + SortMode, + ThumbnailStatus, +} from "./types"; + +const PAGE_SIZE = 60; +const MOBILE_PAGE_SIZE = 36; + +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => + typeof window !== "undefined" && window.matchMedia(query).matches, + ); + + useEffect(() => { + const media = window.matchMedia(query); + const update = () => setMatches(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, [query]); + + return matches; +} + +function useDebounced(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = window.setTimeout(() => setDebounced(value), delay); + return () => window.clearTimeout(timer); + }, [delay, value]); + return debounced; +} + +function formatCount(value: number): string { + return new Intl.NumberFormat("zh-CN").format(value); +} + +function App() { + const [summary, setSummary] = useState(null); + const [status, setStatus] = useState(null); + const [images, setImages] = useState([]); + const [total, setTotal] = useState(0); + const [nextOffset, setNextOffset] = useState(null); + const [album, setAlbum] = useState(""); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("name-asc"); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [reloadToken, setReloadToken] = useState(0); + const compactLayout = useMediaQuery("(max-width: 720px)"); + const pageSize = compactLayout ? MOBILE_PAGE_SIZE : PAGE_SIZE; + const debouncedSearch = useDebounced(search.trim(), 250); + const activeQuery = `${album}\u0000${debouncedSearch}\u0000${sort}`; + const activeQueryRef = useRef(activeQuery); + activeQueryRef.current = activeQuery; + + useLayoutEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + }, [album, debouncedSearch, sort]); + + useEffect(() => { + if (!mobileNavOpen) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setMobileNavOpen(false); + }; + window.addEventListener("keydown", closeOnEscape); + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", closeOnEscape); + }; + }, [mobileNavOpen]); + + useEffect(() => { + if (!compactLayout && mobileNavOpen) setMobileNavOpen(false); + }, [compactLayout, mobileNavOpen]); + + useEffect(() => { + const controller = new AbortController(); + getGallery(controller.signal) + .then(setSummary) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : "无法读取图库"); + } + }); + return () => controller.abort(); + }, [reloadToken]); + + useEffect(() => { + let cancelled = false; + let timer = 0; + const poll = async () => { + try { + const next = await getStatus(); + if (cancelled) return; + setStatus(next); + timer = window.setTimeout(poll, next.backgroundComplete ? 10000 : 1500); + } catch { + if (!cancelled) timer = window.setTimeout(poll, 5000); + } + }; + void poll(); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, []); + + useEffect(() => { + if (status && summary && status.total !== summary.total) { + void getGallery().then(setSummary).catch(() => undefined); + } + }, [status?.total, summary?.total]); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + setImages([]); + setNextOffset(null); + getImages( + { + album, + search: debouncedSearch, + sort, + offset: 0, + limit: pageSize, + }, + controller.signal, + ) + .then((page) => { + setImages(page.items); + setTotal(page.total); + setNextOffset(page.nextOffset); + }) + .catch((reason: unknown) => { + if (!controller.signal.aborted) { + setError(reason instanceof Error ? reason.message : "无法读取图片"); + } + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [album, debouncedSearch, pageSize, reloadToken, sort]); + + const loadMore = useCallback(async () => { + if (nextOffset === null || loadingMore) return; + const queryAtStart = activeQueryRef.current; + setLoadingMore(true); + try { + const page = await getImages({ + album, + search: debouncedSearch, + sort, + offset: nextOffset, + limit: pageSize, + }); + if (queryAtStart !== activeQueryRef.current) return; + setImages((current) => [...current, ...page.items]); + setNextOffset(page.nextOffset); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "无法继续加载图片"); + } finally { + setLoadingMore(false); + } + }, [album, debouncedSearch, loadingMore, nextOffset, pageSize, sort]); + + const sentinelRef = useRef(null); + useEffect(() => { + const sentinel = sentinelRef.current; + if (!sentinel || nextOffset === null) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) void loadMore(); + }, + { rootMargin: compactLayout ? "280px 0px" : "600px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [compactLayout, loadMore, nextOffset]); + + const activeAlbum = summary?.albums.find((item) => item.path === album); + const title = activeAlbum?.name ?? "全部图片"; + const selectedIndex = selectedId + ? images.findIndex((image) => image.id === selectedId) + : -1; + + const openViewer = useCallback((id: string) => { + setSelectedId(id); + }, []); + + const closeViewer = useCallback(() => { + setSelectedId(null); + }, []); + + const chooseAlbum = (path: string) => { + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + setAlbum(path); + setMobileNavOpen(false); + }; + + return ( +
+
setMobileNavOpen(true)} + navigationOpen={mobileNavOpen} + /> + setMobileNavOpen(false)} + status={status} + /> + +
+
+
+

{album || "图库"}

+
+

{title}

+ {formatCount(total)} 张 +
+
+
+ + +
+
+ + {error && ( +
+ {error} + +
+ )} + + {loading ? ( + + ) : images.length ? ( + + ) : ( +
+ + 没有找到图片 + 请调整相册或搜索条件 +
+ )} + +
+ {loadingMore && } + {!loading && nextOffset === null && images.length > 0 && ( + 已显示全部 {formatCount(total)} 张图片 + )} +
+
+ + {selectedIndex >= 0 && ( + setSelectedId(images[index]?.id ?? null)} + onNeedMore={() => void loadMore()} + onClose={closeViewer} + /> + )} +
+ ); +} + +function Header({ + status, + onOpenNavigation, + navigationOpen, +}: { + status: ThumbnailStatus | null; + onOpenNavigation: () => void; + navigationOpen: boolean; +}) { + return ( +
+ +
+ + Pixhelf +
+ +
+ ); +} + +function StatusCompact({ status }: { status: ThumbnailStatus | null }) { + if (!status) return 连接中; + const complete = status.backgroundComplete; + return ( +
+ {complete ? : } + {formatCount(status.ready)} / {formatCount(status.total)} +
+ ); +} + +function Sidebar({ + summary, + activeAlbum, + onChoose, + mobileOpen, + onClose, + status, +}: { + summary: GallerySummary | null; + activeAlbum: string; + onChoose: (path: string) => void; + mobileOpen: boolean; + onClose: () => void; + status: ThumbnailStatus | null; +}) { + const navigation = ( + <> +
+ 相册 + +
+ + + + ); + + return ( + <> + + {mobileOpen && ( +
+
+ )} + + ); +} + +function AlbumButton({ + label, + detail, + count, + active, + onClick, + all = false, +}: { + label: string; + detail?: string; + count: number; + active: boolean; + onClick: () => void; + all?: boolean; +}) { + return ( + + ); +} + +function SidebarProgress({ status }: { status: ThumbnailStatus | null }) { + if (!status) return null; + const percent = status.total ? Math.round((status.ready / status.total) * 100) : 100; + return ( +
+
+ {status.backgroundComplete ? "缩略图已就绪" : "后台处理中"} + {percent}% +
+ + {status.failed > 0 && {status.failed} 个任务失败} +
+ ); +} + +function useColumnCount(ref: RefObject): number { + const [columns, setColumns] = useState(5); + useEffect(() => { + const element = ref.current; + if (!element) return; + const update = (width: number) => { + const minimumCardWidth = 218; + const gap = 6; + const next = Math.floor((width + gap) / (minimumCardWidth + gap)); + setColumns(Math.min(6, Math.max(2, next))); + }; + update(element.clientWidth); + const observer = new ResizeObserver(([entry]) => update(entry.contentRect.width)); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + return columns; +} + +function MasonryGallery({ + images, + onOpen, +}: { + images: GalleryImage[]; + onOpen: (id: string) => void; +}) { + const ref = useRef(null); + const columnCount = useColumnCount(ref); + const columns = useMemo(() => { + const result: { image: GalleryImage; index: number }[][] = Array.from( + { length: columnCount }, + () => [], + ); + const heights = Array(columnCount).fill(0) as number[]; + images.forEach((image, index) => { + const target = heights.indexOf(Math.min(...heights)); + result[target].push({ image, index }); + heights[target] += image.height / Math.max(image.width, 1) + 0.05; + }); + return result; + }, [columnCount, images]); + + return ( +
+ {columns.map((column, columnIndex) => ( +
+ {column.map(({ image, index }) => ( + + ))} +
+ ))} +
+ ); +} + +function ImageCard({ + image, + index, + onOpen, +}: { + image: GalleryImage; + index: number; + onOpen: (id: string) => void; +}) { + const [loaded, setLoaded] = useState(false); + const [failed, setFailed] = useState(false); + return ( + + ); +} + +function GallerySkeleton() { + const ratios = [1.4, 0.72, 1, 1.55, 0.8, 1.2, 0.67, 1.35, 0.9, 1.6, 0.76, 1.1]; + return ( +
+ {ratios.map((ratio, index) => ( + + ))} +
+ ); +} + +export default App; diff --git a/frontend/src/ImageViewer.tsx b/frontend/src/ImageViewer.tsx new file mode 100644 index 0000000..d573ca4 --- /dev/null +++ b/frontend/src/ImageViewer.tsx @@ -0,0 +1,855 @@ +import { + ChevronLeft, + ChevronRight, + Download, + ImageOff, + LoaderCircle, + Maximize, + Minimize, + Minus, + Plus, + RefreshCw, + Scan, + X, +} from "lucide-react"; +import { + type CSSProperties, + type SyntheticEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import type { GalleryImage } from "./types"; +import { type Point, useViewerEngine } from "./viewer/useViewerEngine"; + +const DOUBLE_TAP_DELAY = 230; +const VIEWER_PREVIEW_EDGE = 2560; + +type SourceStatus = "loading" | "ready" | "failed"; + +interface SourceState { + imageId: string; + status: SourceStatus; +} + +interface PendingTap extends Point { + time: number; + pointerType: string; +} + +interface RetryState { + imageId: string; + attempt: number; +} + +interface ImageViewerProps { + images: GalleryImage[]; + index: number; + total: number; + onIndexChange: (index: number) => void; + onNeedMore: () => void; + onClose: () => void; +} + +interface ViewportSize { + width: number; + height: number; +} + +function viewportSize(): ViewportSize { + return { + width: Math.round(window.visualViewport?.width ?? window.innerWidth), + height: Math.round(window.visualViewport?.height ?? window.innerHeight), + }; +} + +function useViewportSize(): ViewportSize { + const [size, setSize] = useState(viewportSize); + + useEffect(() => { + let frame = 0; + const update = () => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => setSize(viewportSize())); + }; + window.addEventListener("resize", update); + window.visualViewport?.addEventListener("resize", update); + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + }; + }, []); + + return size; +} + +function fittedSize(image: GalleryImage, viewport: ViewportSize) { + const compact = viewport.width <= 720; + const availableWidth = Math.max(1, viewport.width - (compact ? 8 : 72)); + const availableHeight = Math.max(1, viewport.height - (compact ? 8 : 40)); + const ratio = Math.min( + availableWidth / Math.max(image.width, 1), + availableHeight / Math.max(image.height, 1), + 1, + ); + return { + width: Math.max(1, Math.round(image.width * ratio)), + height: Math.max(1, Math.round(image.height * ratio)), + }; +} + +function maximumZoom( + image: GalleryImage, + frame: ViewportSize, + previewReady: boolean, + originalReady: boolean, +) { + if (!previewReady && !originalReady) return 1; + const sourceEdge = originalReady + ? Math.max(image.width, image.height) + : Math.min(VIEWER_PREVIEW_EDGE, Math.max(image.width, image.height)); + const nativeScale = sourceEdge / Math.max(frame.width, frame.height, 1); + return Math.max(1, Math.min(6, nativeScale)); +} + +function formatCount(value: number): string { + return new Intl.NumberFormat("zh-CN").format(value); +} + +function formatBytes(value: number): string { + if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} + +export default function ImageViewer({ + images, + index, + total, + onIndexChange, + onNeedMore, + onClose, +}: ImageViewerProps) { + const image = images[index]; + const previous = images[index - 1] ?? null; + const next = images[index + 1] ?? null; + const viewport = useViewportSize(); + const compact = viewport.width <= 720; + const frameSize = useMemo(() => fittedSize(image, viewport), [image, viewport]); + const slideSpan = viewport.width + (compact ? 12 : 28); + const [sourceState, setSourceState] = useState({ + imageId: image.id, + status: "loading", + }); + const [previewState, setPreviewState] = useState({ + imageId: image.id, + status: "loading", + }); + const [thumbnailState, setThumbnailState] = useState({ + imageId: image.id, + status: "loading", + }); + // Keep the large source out of the network queue while the sharp preview is + // being fetched. A short timeout keeps the original a reliable fallback on + // slow or unavailable preview endpoints. + const [originalGate, setOriginalGate] = useState({ imageId: image.id, open: false }); + const [retryState, setRetryState] = useState({ + imageId: image.id, + attempt: 0, + }); + const sourceAttempt = retryState.imageId === image.id ? retryState.attempt : 0; + const [controlsVisible, setControlsVisible] = useState(true); + const [closing, setClosing] = useState(false); + const [fullscreen, setFullscreen] = useState(Boolean(document.fullscreenElement)); + const [pendingNext, setPendingNext] = useState(false); + const [announcement, setAnnouncement] = useState(""); + + const rootRef = useRef(null); + const currentImageIdRef = useRef(image.id); + const sourceAttemptRef = useRef(sourceAttempt); + const closeTimerRef = useRef(0); + const controlsTimerRef = useRef(0); + const tapTimerRef = useRef(0); + const pendingTimerRef = useRef(0); + const pendingRetryRef = useRef(0); + const tapRef = useRef(null); + const tapHandlerRef = useRef<(point: Point, pointerType: string) => void>(() => undefined); + currentImageIdRef.current = image.id; + sourceAttemptRef.current = sourceAttempt; + + const originalReady = sourceState.imageId === image.id && sourceState.status === "ready"; + const originalFailed = sourceState.imageId === image.id && sourceState.status === "failed"; + const previewReady = previewState.imageId === image.id && previewState.status === "ready"; + const thumbnailReady = thumbnailState.imageId === image.id && thumbnailState.status === "ready"; + const originalEnabled = originalGate.imageId === image.id && originalGate.open; + const maxZoom = useMemo( + () => maximumZoom( + image, + frameSize, + previewReady, + originalReady, + ), + [frameSize, image, originalReady, previewReady], + ); + + const clearTap = useCallback(() => { + window.clearTimeout(tapTimerRef.current); + tapTimerRef.current = 0; + tapRef.current = null; + }, []); + + const scheduleControlsHide = useCallback(() => { + window.clearTimeout(controlsTimerRef.current); + controlsTimerRef.current = window.setTimeout(() => { + const root = rootRef.current; + if (root?.querySelector(".viewer-chrome:focus-within")) return; + setControlsVisible(false); + }, compact ? 1700 : 1400); + }, [compact]); + + const revealControls = useCallback(() => { + setControlsVisible(true); + scheduleControlsHide(); + }, [scheduleControlsHide]); + + const requestOriginal = useCallback(() => { + setOriginalGate((current) => + current.imageId === image.id && !current.open + ? { imageId: image.id, open: true } + : current, + ); + }, [image.id]); + + const requestClose = useCallback(() => { + if (closing) return; + clearTap(); + window.clearTimeout(controlsTimerRef.current); + setClosing(true); + if (document.fullscreenElement === rootRef.current) { + void document.exitFullscreen().catch(() => undefined); + } + closeTimerRef.current = window.setTimeout(onClose, 170); + }, [clearTap, closing, onClose]); + + const handleNavigate = useCallback((direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= images.length) return; + clearTap(); + setPendingNext(false); + onIndexChange(target); + revealControls(); + }, [clearTap, images.length, index, onIndexChange, revealControls]); + + const handleEdge = useCallback((direction: -1 | 1) => { + if (direction > 0 && index + 1 < total && !pendingNext) { + setPendingNext(true); + onNeedMore(); + } + }, [index, onNeedMore, pendingNext, total]); + + const engine = useViewerEngine({ + imageId: image.id, + frameSize, + maximumZoom: maxZoom, + canPrevious: Boolean(previous), + canNext: Boolean(next), + onNavigate: handleNavigate, + onEdge: handleEdge, + onDismiss: requestClose, + onTap: (point, pointerType) => tapHandlerRef.current(point, pointerType), + onDragStart: clearTap, + onInteraction: () => { + revealControls(); + requestOriginal(); + }, + }); + + const commitSingleTap = useCallback(() => { + setControlsVisible((visible) => { + if (visible) window.clearTimeout(controlsTimerRef.current); + else scheduleControlsHide(); + return !visible; + }); + }, [scheduleControlsHide]); + + const handleTap = useCallback((point: Point, pointerType: string) => { + const now = performance.now(); + const pending = tapRef.current; + const tapDelay = pointerType === "touch" ? DOUBLE_TAP_DELAY : 200; + const threshold = pointerType === "touch" ? 34 : 24; + const compatible = pending && + pending.pointerType === pointerType && + now - pending.time <= tapDelay + 40 && + Math.hypot(pending.x - point.x, pending.y - point.y) <= threshold; + + if (compatible) { + clearTap(); + engine.toggleZoomAt(point); + return; + } + if (pending) { + window.clearTimeout(tapTimerRef.current); + commitSingleTap(); + } + tapRef.current = { ...point, time: now, pointerType }; + tapTimerRef.current = window.setTimeout(() => { + tapRef.current = null; + tapTimerRef.current = 0; + commitSingleTap(); + }, tapDelay); + }, [clearTap, commitSingleTap, engine]); + + tapHandlerRef.current = handleTap; + + const canvasHandlers = { + onPointerDown: engine.handlePointerDown, + onPointerMove: engine.handlePointerMove, + onPointerUp: engine.handlePointerUp, + onPointerCancel: engine.handlePointerCancel, + onLostPointerCapture: engine.handleLostPointerCapture, + }; + + useEffect(() => { + setAnnouncement(`${image.name},第 ${index + 1} 张,共 ${total} 张`); + }, [image.id, image.name, index, total]); + + useEffect(() => { + if (!pendingNext || index + 1 >= images.length) return; + let cancelled = false; + const advance = () => { + if (cancelled) return; + if (engine.navigate(1)) { + setPendingNext(false); + return; + } + pendingRetryRef.current = window.setTimeout(advance, 120); + }; + advance(); + return () => { + cancelled = true; + window.clearTimeout(pendingRetryRef.current); + }; + }, [engine.navigate, images.length, index, pendingNext]); + + useEffect(() => { + if (!pendingNext) return; + window.clearTimeout(pendingTimerRef.current); + pendingTimerRef.current = window.setTimeout(() => setPendingNext(false), 8000); + return () => window.clearTimeout(pendingTimerRef.current); + }, [pendingNext]); + + useEffect(() => { + if (index >= images.length - 4 && index + 1 < total) onNeedMore(); + }, [images.length, index, onNeedMore, total]); + + const handleOriginalLoad = useCallback(async (event: SyntheticEvent) => { + const element = event.currentTarget; + const imageId = element.dataset.imageId; + const attempt = Number(element.dataset.sourceAttempt ?? -1); + try { + await element.decode(); + } catch { + // A completed image can still reject decode on some WebKit versions. + } + if ( + imageId && + imageId === currentImageIdRef.current && + attempt === sourceAttemptRef.current && + element.isConnected && + element.complete && + element.naturalWidth > 0 + ) { + setSourceState({ imageId, status: "ready" }); + } + }, []); + + const handleThumbnailLoad = useCallback((event: SyntheticEvent) => { + const imageId = event.currentTarget.dataset.imageId; + if ( + imageId && + imageId === currentImageIdRef.current && + event.currentTarget.isConnected && + event.currentTarget.complete && + event.currentTarget.naturalWidth > 0 + ) { + setThumbnailState({ imageId, status: "ready" }); + } + }, []); + + const handlePreviewLoad = useCallback(async (event: SyntheticEvent) => { + const element = event.currentTarget; + const imageId = element.dataset.imageId; + try { + await element.decode(); + } catch { + // A completed image can still reject decode on some WebKit versions. + } + if ( + imageId && + imageId === currentImageIdRef.current && + element.isConnected && + element.complete && + element.naturalWidth > 0 + ) { + setPreviewState({ imageId, status: "ready" }); + } + }, []); + + const handlePreviewError = useCallback((event: SyntheticEvent) => { + const imageId = event.currentTarget.dataset.imageId; + if (imageId && imageId === currentImageIdRef.current && event.currentTarget.isConnected) { + setPreviewState({ imageId, status: "failed" }); + } + }, []); + + useEffect(() => { + setSourceState({ imageId: image.id, status: "loading" }); + setPreviewState({ imageId: image.id, status: "loading" }); + setThumbnailState({ imageId: image.id, status: "loading" }); + setOriginalGate({ imageId: image.id, open: false }); + + const timer = window.setTimeout(() => { + setOriginalGate((current) => + current.imageId === image.id ? { imageId: image.id, open: true } : current, + ); + }, 350); + return () => window.clearTimeout(timer); + }, [image.id]); + + useEffect(() => { + const previewFailed = previewState.imageId === image.id && previewState.status === "failed"; + if (!previewReady && !previewFailed) return; + setOriginalGate((current) => + current.imageId === image.id ? { imageId: image.id, open: true } : current, + ); + }, [image.id, previewReady, previewState.status]); + + const handleOriginalError = useCallback((event: SyntheticEvent) => { + const element = event.currentTarget; + const imageId = element.dataset.imageId; + const attempt = Number(element.dataset.sourceAttempt ?? -1); + if ( + imageId && + imageId === currentImageIdRef.current && + attempt === sourceAttemptRef.current && + element.isConnected + ) { + setSourceState({ imageId, status: "failed" }); + } + }, []); + + useEffect(() => { + if (!previewReady || !next?.previewUrl) return; + let cancelled = false; + const prefetchTimer = window.setTimeout(() => { + if (cancelled) return; + const preloader = new Image(); + preloader.decoding = "async"; + preloader.fetchPriority = "low"; + preloader.src = next.previewUrl; + }, 280); + return () => { + cancelled = true; + window.clearTimeout(prefetchTimer); + }; + }, [image.id, next?.previewUrl, previewReady]); + + const retryOriginal = useCallback(() => { + setSourceState({ imageId: image.id, status: "loading" }); + setRetryState((current) => ({ + imageId: image.id, + attempt: current.imageId === image.id ? current.attempt + 1 : 1, + })); + revealControls(); + }, [image.id, revealControls]); + + const toggleFullscreen = useCallback(async () => { + try { + if (document.fullscreenElement) await document.exitFullscreen(); + else await rootRef.current?.requestFullscreen(); + } catch { + // Browsers may deny fullscreen in embedded contexts. + } + }, []); + + useEffect(() => { + const update = () => setFullscreen(document.fullscreenElement === rootRef.current); + document.addEventListener("fullscreenchange", update); + return () => document.removeEventListener("fullscreenchange", update); + }, []); + + useEffect(() => { + const previousFocus = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const app = document.querySelector(".app-shell"); + const previousInert = app?.inert ?? false; + const previousOverflow = document.body.style.overflow; + const previousPadding = document.body.style.paddingRight; + const scrollbar = window.innerWidth - document.documentElement.clientWidth; + if (app) app.inert = true; + document.body.style.overflow = "hidden"; + if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`; + const frame = window.requestAnimationFrame(() => rootRef.current?.focus()); + scheduleControlsHide(); + return () => { + window.cancelAnimationFrame(frame); + if (app) app.inert = previousInert; + document.body.style.overflow = previousOverflow; + document.body.style.paddingRight = previousPadding; + previousFocus?.focus({ preventScroll: true }); + }; + }, [scheduleControlsHide]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Tab") { + const controlsHidden = rootRef.current?.dataset.controlsVisible === "false"; + const focusable = [...(rootRef.current?.querySelectorAll( + 'button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])', + ) ?? [])].filter((element) => + element.offsetParent !== null && + (!controlsHidden || element.classList.contains("viewer-essential")) + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable.at(-1)!; + if (document.activeElement === rootRef.current) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + return; + } + if (event.altKey || event.ctrlKey || event.metaKey) return; + switch (event.key) { + case "Escape": + event.preventDefault(); + requestClose(); + break; + case "ArrowLeft": + event.preventDefault(); + engine.navigate(-1); + break; + case "ArrowRight": + event.preventDefault(); + engine.navigate(1); + break; + case "+": + case "=": + event.preventDefault(); + engine.zoomBy(1.4); + break; + case "-": + event.preventDefault(); + engine.zoomBy(1 / 1.4); + break; + case "0": + event.preventDefault(); + engine.resetZoom(); + break; + case "f": + case "F": + event.preventDefault(); + void toggleFullscreen(); + break; + case " ": + if ((event.target as HTMLElement | null)?.closest("button, a, input, select, textarea")) { + return; + } + event.preventDefault(); + commitSingleTap(); + break; + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [commitSingleTap, engine, requestClose, toggleFullscreen]); + + useEffect(() => () => { + window.clearTimeout(closeTimerRef.current); + window.clearTimeout(controlsTimerRef.current); + window.clearTimeout(tapTimerRef.current); + window.clearTimeout(pendingTimerRef.current); + window.clearTimeout(pendingRetryRef.current); + }, []); + + const originalUrl = sourceAttempt + ? `${image.originalUrl}?retry=${sourceAttempt}` + : image.originalUrl; + const frameStyle = { + width: frameSize.width, + height: frameSize.height, + } satisfies CSSProperties; + + return createPortal( +
{ + if (event.pointerType === "mouse" && event.buttons === 0) revealControls(); + }} + > +
+
+ {previous && ( + + )} + +
+
+
event.preventDefault()} + > +
+ + + {originalEnabled && ( + {image.name} + )} + {!thumbnailReady && !previewReady && !originalReady && !originalFailed && ( + + + + )} +
+
+
+
+ + {next && ( + + )} +
+
+ +
window.clearTimeout(controlsTimerRef.current)} + onPointerLeave={scheduleControlsHide} + onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)} + onBlurCapture={scheduleControlsHide} + > +
+ + {formatCount(index + 1)} / {formatCount(total)} + + + {image.name} + + {formatCount(image.width)} x {formatCount(image.height)} · {formatBytes(image.size)} + + +
+
+ + + + + + {!compact && ( + <> + engine.navigate(-1)} + disabled={!previous} + > + + + engine.navigate(1)} + disabled={index + 1 >= total} + > + {pendingNext ? : } + + + )} + +
window.clearTimeout(controlsTimerRef.current)} + onPointerLeave={scheduleControlsHide} + onFocusCapture={() => window.clearTimeout(controlsTimerRef.current)} + onBlurCapture={scheduleControlsHide} + > + {!compact && ( + <> + engine.zoomBy(1 / 1.4)} disabled={!engine.canZoom}> + + + + {engine.settledScale <= 1.01 ? "适应" : `${Math.round(engine.settledScale * 100)}%`} + + engine.zoomBy(1.4)} disabled={!engine.canZoom}> + + + + )} + + + +
+ + {originalFailed && ( +
+ + 原图载入失败 + +
+ )} + +

{announcement}

+
, + document.body, + ); +} + +function NeighborSlide({ + image, + viewport, + offset, +}: { + image: GalleryImage; + viewport: ViewportSize; + offset: number; +}) { + const size = fittedSize(image, viewport); + return ( + + ); +} + +function ViewerButton({ + label, + onClick, + className = "", + disabled = false, + children, +}: { + label: string; + onClick: () => void; + className?: string; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..363f077 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,43 @@ +import type { + GallerySummary, + ImagesPage, + SortMode, + ThumbnailStatus, +} from "./types"; + +async function getJson(url: string, signal?: AbortSignal): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`请求失败 (${response.status})`); + } + return (await response.json()) as T; +} + +export function getGallery(signal?: AbortSignal): Promise { + return getJson("/api/gallery", signal); +} + +export function getStatus(signal?: AbortSignal): Promise { + return getJson("/api/status", signal); +} + +export function getImages( + options: { + album: string; + search: string; + sort: SortMode; + offset: number; + limit: number; + }, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + offset: String(options.offset), + limit: String(options.limit), + sort: options.sort, + }); + if (options.album) params.set("album", options.album); + if (options.search) params.set("search", options.search); + return getJson(`/api/images?${params}`, signal); +} + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..60b661a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); + diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..23390e6 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,1153 @@ +:root { + color: #1b1d1e; + background: #f4f5f4; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + --topbar-height: 62px; + --sidebar-width: 238px; + --bg: #f4f5f4; + --surface: #ffffff; + --ink: #1b1d1e; + --muted: #6a7073; + --border: #dfe2e0; + --accent: #147d73; + --accent-soft: #dceeea; + --warm: #d2673a; + --danger: #b44343; + letter-spacing: 0; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-width: 320px; + min-height: 100%; + min-height: 100dvh; + margin: 0; +} + +body { + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); +} + +button, +input, +select { + font: inherit; + letter-spacing: 0; +} + +button, +select { + cursor: pointer; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.app-shell { + min-height: 100vh; + min-height: 100dvh; +} + +.topbar { + position: fixed; + z-index: 30; + inset: 0 0 auto 0; + height: var(--topbar-height); + display: flex; + align-items: center; + gap: 14px; + padding: 0 24px; + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + min-width: calc(var(--sidebar-width) - 24px); + color: var(--ink); + font-size: 18px; + font-weight: 720; +} + +.brand-mark { + width: 34px; + height: 34px; + display: grid; + place-items: center; + color: #fff; + background: var(--accent); + border-radius: 7px; + box-shadow: inset -4px -4px 0 rgba(210, 103, 58, 0.24); +} + +.icon-button { + width: 36px; + height: 36px; + display: grid; + place-items: center; + padding: 0; + color: var(--muted); + background: transparent; + border: 0; + border-radius: 6px; +} + +.icon-button:hover { + color: var(--ink); + background: #eceeec; +} + +.mobile-menu, +.sidebar-close { + display: none; +} + +.status-compact { + margin-left: auto; + min-width: 118px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 7px 10px; + color: #8c4a2c; + background: #f7e8df; + border: 1px solid #edcdbb; + border-radius: 6px; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.status-compact.complete { + color: #0f655d; + background: var(--accent-soft); + border-color: #b8dcd5; +} + +.status-compact.muted { + color: var(--muted); + background: transparent; + border-color: var(--border); +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.sidebar { + display: flex; + flex-direction: column; + background: #fafbfa; + border-right: 1px solid var(--border); +} + +.desktop-sidebar { + position: fixed; + z-index: 20; + top: var(--topbar-height); + bottom: 0; + left: 0; + width: var(--sidebar-width); +} + +.sidebar-heading { + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px 0 20px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.album-nav { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0 10px 16px; +} + +.album-link { + position: relative; + width: 100%; + min-height: 44px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 7px 10px; + color: #4f5558; + text-align: left; + background: transparent; + border: 0; + border-radius: 5px; +} + +.album-link:hover { + color: var(--ink); + background: #eef0ee; +} + +.album-link.active { + color: #0f655d; + background: var(--accent-soft); +} + +.album-link.active::before { + content: ""; + position: absolute; + inset: 8px auto 8px 0; + width: 3px; + background: var(--accent); + border-radius: 2px; +} + +.album-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.album-copy strong, +.album-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.album-copy strong { + font-size: 13px; + font-weight: 620; +} + +.album-copy small { + color: var(--muted); + font-size: 10px; +} + +.album-count { + color: var(--muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.sidebar-progress { + padding: 14px 16px 17px; + border-top: 1px solid var(--border); +} + +.sidebar-progress > div { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + color: var(--muted); + font-size: 11px; +} + +.sidebar-progress strong { + color: var(--ink); + font-variant-numeric: tabular-nums; +} + +.sidebar-progress progress { + width: 100%; + height: 4px; + display: block; + overflow: hidden; + appearance: none; + background: #dfe4e1; + border: 0; + border-radius: 2px; +} + +.sidebar-progress progress::-webkit-progress-bar { background: #dfe4e1; } +.sidebar-progress progress::-webkit-progress-value { background: var(--accent); } +.sidebar-progress progress::-moz-progress-bar { background: var(--accent); } + +.sidebar-progress small { + display: block; + margin-top: 7px; + color: var(--danger); + font-size: 10px; +} + +.content { + min-height: 100vh; + margin-left: var(--sidebar-width); + padding: calc(var(--topbar-height) + 20px) 18px 34px; + overflow-anchor: none; +} + +.content-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 16px; +} + +.title-block { + min-width: 0; +} + +.path-label { + min-height: 17px; + max-width: 52vw; + margin: 0 0 4px; + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.title-line { + display: flex; + align-items: baseline; + gap: 12px; +} + +.title-line h1 { + max-width: min(480px, 48vw); + margin: 0; + overflow: hidden; + color: var(--ink); + font-size: 25px; + font-weight: 720; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.title-line span { + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + +.toolbar { + display: flex; + align-items: center; + gap: 9px; +} + +.search-field, +.sort-control { + height: 38px; + display: flex; + align-items: center; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; +} + +.search-field { + width: min(250px, 24vw); + padding: 0 8px 0 11px; +} + +.search-field:focus-within, +.sort-control:focus-within { + border-color: #74aea7; + box-shadow: 0 0 0 2px rgba(20, 125, 115, 0.1); +} + +.search-field input { + min-width: 0; + flex: 1; + height: 100%; + padding: 0 7px; + color: var(--ink); + background: transparent; + border: 0; + outline: 0; + font-size: 13px; +} + +.search-field input::placeholder { color: #959a9c; } + +.clear-search { + width: 25px; + height: 25px; + display: grid; + place-items: center; + padding: 0; + color: var(--muted); + background: transparent; + border: 0; + border-radius: 4px; +} + +.clear-search:hover { background: #eceeec; } + +.sort-control { + padding-left: 11px; +} + +.sort-control select { + height: 100%; + padding: 0 28px 0 7px; + color: var(--ink); + background: transparent; + border: 0; + outline: 0; + font-size: 13px; +} + +.error-banner { + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 16px; + padding: 8px 10px 8px 14px; + color: #813131; + background: #f8e5e5; + border: 1px solid #ecc5c5; + border-radius: 6px; + font-size: 13px; +} + +.error-banner button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + flex: 0 0 auto; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 5px; +} + +.error-banner button:hover { background: #f1d1d1; } + +.masonry { + display: grid; + grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); + align-items: start; + gap: 6px; +} + +.masonry-column { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.image-card { + position: relative; + width: 100%; + min-height: 64px; + display: block; + overflow: hidden; + padding: 0; + background: #e5e8e6; + border: 1px solid rgba(26, 35, 31, 0.08); + border-radius: 5px; + box-shadow: 0 1px 1px rgba(21, 31, 27, 0.05); +} + +.image-card::after { + content: ""; + position: absolute; + inset: 0; + border: 2px solid transparent; + border-radius: 4px; + pointer-events: none; +} + +.image-card:hover::after, +.image-card:focus-visible::after { + border-color: rgba(20, 125, 115, 0.72); +} + +.image-card img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + opacity: 0; + transition: opacity 180ms ease, transform 180ms ease; +} + +.image-card img.loaded { opacity: 1; } +.image-card:hover img { transform: scale(1.01); } + +.image-name { + position: absolute; + inset: auto 4px 4px; + overflow: hidden; + padding: 4px 6px; + color: #fff; + background: rgba(20, 23, 22, 0.82); + border-radius: 4px; + font-size: 10px; + line-height: 1.25; + opacity: 0; + text-overflow: ellipsis; + transform: translateY(3px); + transition: opacity 160ms ease, transform 160ms ease; + white-space: nowrap; +} + +.image-card:hover .image-name, +.image-card:focus-visible .image-name { + opacity: 1; + transform: translateY(0); +} + +.image-fallback { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #8c9390; +} + +.skeleton-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(218px, 1fr)); + align-items: start; + gap: 6px; +} + +.skeleton-grid span { + min-height: 100px; + background: #e4e7e5; + border-radius: 5px; + animation: pulse 1.4s ease-in-out infinite alternate; +} + +@keyframes pulse { + from { opacity: 0.62; } + to { opacity: 1; } +} + +.empty-state { + min-height: 44vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--muted); + text-align: center; +} + +.empty-state strong { + margin-top: 5px; + color: var(--ink); + font-size: 15px; +} + +.empty-state span { font-size: 12px; } + +.load-sentinel { + height: 64px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 11px; +} + +.image-viewer { + --viewer-dismiss-progress: 0; + position: fixed; + z-index: 200; + inset: 0; + overflow: hidden; + color: #f7f8f8; + background: transparent; + isolation: isolate; + outline: 0; + overscroll-behavior: none; + touch-action: none; + user-select: none; + animation: viewer-enter 180ms cubic-bezier(.22,.75,.18,1) both; +} + +.image-viewer.is-closing { + animation: viewer-exit 170ms cubic-bezier(.4,0,1,1) both; + pointer-events: none; +} + +@keyframes viewer-enter { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes viewer-exit { + from { opacity: 1; } + to { opacity: 0; } +} + +.viewer-canvas { + position: absolute; + inset: 0; + overflow: hidden; + cursor: zoom-in; + touch-action: none; + -webkit-tap-highlight-color: transparent; +} + +.viewer-canvas::before { + content: ""; + position: absolute; + z-index: 0; + inset: 0; + background: #090a0c; + opacity: calc(1 - var(--viewer-dismiss-progress) * .9); + pointer-events: none; +} + +.viewer-track { + position: absolute; + z-index: 1; + inset: 0; + transform: translate3d(0, 0, 0); + transform-origin: center; +} + +.viewer-slide { + position: absolute; + inset: 0; + display: grid; + place-items: center; + pointer-events: none; +} + +.viewer-slide-current { z-index: 2; } +.viewer-slide-neighbor { z-index: 1; } + +.viewer-media { + position: relative; + flex: 0 0 auto; + contain: layout style; +} + +.viewer-transform { + position: absolute; + left: 0; + top: 0; + z-index: 1; + width: 100%; + height: 100%; + transform: translate3d(0, 0, 0) scale(1); + transform-origin: center; + will-change: var(--viewer-will-change, auto); + touch-action: none; +} + +.viewer-canvas:has(.viewer-transform[data-zoomed="true"]) { cursor: grab; } +.image-viewer[data-interacting="true"] .viewer-canvas { cursor: grabbing; } + +.viewer-image-frame { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: #0d0f11; + border-radius: 1px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .045); + isolation: isolate; + user-select: none; +} + +.viewer-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + object-fit: contain; + image-rendering: auto; + filter: none; + backface-visibility: hidden; + user-select: none; + -webkit-user-drag: none; +} + +.viewer-image-thumbnail { opacity: 1; } +.viewer-image-thumbnail.is-replaced { opacity: 1; } + +.viewer-image-preview { + opacity: 0; +} + +.viewer-image-preview.is-ready { opacity: 1; } + +.viewer-image-original { + opacity: 0; +} + +.viewer-image-original.is-ready { opacity: 1; } + +.viewer-loading { + position: absolute; + right: 10px; + bottom: 10px; + width: 28px; + height: 28px; + display: grid; + place-items: center; + color: rgba(238, 244, 242, .64); + background: rgba(10, 12, 14, .38); + border: 0; + border-radius: 50%; + pointer-events: none; +} + +.viewer-neighbor-frame { + position: relative; + overflow: hidden; + background: #0d0f11; + border-radius: 1px; +} + +.viewer-neighbor-frame img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + image-rendering: auto; + -webkit-user-drag: none; +} + +.viewer-chrome { + opacity: 1; + transition: opacity 120ms ease, transform 160ms cubic-bezier(.22,.75,.18,1); +} + +.image-viewer[data-controls-visible="false"] .viewer-chrome { + opacity: 0; + pointer-events: none; +} + +.image-viewer[data-interacting="true"] .viewer-chrome { + opacity: 0; + pointer-events: none; +} + +.viewer-topbar { + position: absolute; + z-index: 5; + top: 0; + right: 0; + left: 0; + display: flex; + align-items: center; + min-height: 70px; + padding: + max(12px, env(safe-area-inset-top)) + max(82px, calc(env(safe-area-inset-right) + 72px)) + 20px + max(20px, env(safe-area-inset-left)); + background: linear-gradient(180deg, rgba(5, 6, 8, .56), rgba(5, 6, 8, 0)); + pointer-events: none; +} + +.viewer-about { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + text-shadow: 0 1px 8px rgba(0, 0, 0, .65); +} + +.viewer-counter { + flex: 0 0 auto; + min-width: 58px; + color: rgba(250, 251, 251, .96); + font-size: 13px; + font-weight: 650; + font-variant-numeric: tabular-nums; +} + +.viewer-counter i { + padding: 0 4px; + color: rgba(224, 230, 228, .48); + font-style: normal; + font-weight: 400; +} + +.viewer-file-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.viewer-file-copy strong, +.viewer-file-copy small { + max-width: min(48vw, 620px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-file-copy strong { color: #f6f8f7; font-size: 13px; font-weight: 600; } +.viewer-file-copy small { color: rgba(222, 229, 227, .46); font-size: 10px; } + +.image-viewer[data-controls-visible="false"] .viewer-topbar { + transform: translateY(-8px); +} + +.viewer-zoom-value { + min-width: 48px; + color: rgba(231, 237, 235, .68); + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.viewer-icon-button { + width: 46px; + height: 46px; + flex: 0 0 46px; + display: grid; + place-items: center; + padding: 0; + color: rgba(238, 243, 241, .82); + background: transparent; + border: 0; + border-radius: 6px; + text-decoration: none; + -webkit-tap-highlight-color: transparent; + transition: color 120ms ease, background 120ms ease, transform 100ms ease, opacity 120ms ease; +} + +.viewer-icon-button:hover, +.viewer-icon-button:focus-visible { + color: #fff; + background: rgba(255, 255, 255, .11); +} + +.viewer-icon-button:active { transform: scale(.93); } +.viewer-icon-button:disabled { opacity: .28; cursor: default; } +.viewer-icon-button:disabled:active { transform: none; } + +.image-viewer button:focus-visible, +.image-viewer a:focus-visible { + outline-color: #80d8c2; + outline-offset: 2px; +} + +.viewer-close { + position: absolute; + z-index: 8; + top: max(14px, env(safe-area-inset-top)); + right: max(16px, env(safe-area-inset-right)); + width: 42px; + height: 42px; + flex-basis: 42px; + color: rgba(250, 251, 251, .9); + background: rgba(20, 22, 25, .42); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 50%; + box-shadow: none; +} + +.viewer-close:hover, +.viewer-close:focus-visible { background: rgba(46, 49, 53, .82); } + +.viewer-nav { + position: absolute; + z-index: 4; + top: 50%; + width: 42px; + height: 42px; + flex-basis: 42px; + color: rgba(248, 250, 249, .86); + background: rgba(19, 21, 24, .34); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 50%; + box-shadow: none; + transform: translateY(-50%); +} + +.viewer-nav:hover:not(:disabled), +.viewer-nav:focus-visible:not(:disabled) { background: rgba(54, 58, 62, .8); } +.viewer-nav:disabled { opacity: .22; } +.viewer-nav-previous { left: max(20px, env(safe-area-inset-left)); } +.viewer-nav-next { right: max(20px, env(safe-area-inset-right)); } + +.image-viewer[data-controls-visible="false"] .viewer-nav { + transform: translateY(-50%) scale(.9); +} + +.viewer-dock { + position: absolute; + z-index: 6; + bottom: max(18px, calc(env(safe-area-inset-bottom) + 12px)); + left: 50%; + max-width: calc(100vw - 24px); + display: flex; + align-items: center; + gap: 1px; + padding: 2px; + background: rgba(20, 22, 25, .48); + border: 1px solid rgba(255, 255, 255, .1); + border-radius: 8px; + box-shadow: none; + transform: translateX(-50%); +} + +.image-viewer[data-controls-visible="false"] .viewer-dock { + transform: translate(-50%, 8px); +} + +.viewer-dock-divider { + width: 1px; + height: 22px; + flex: 0 0 1px; + margin: 0 3px; + background: rgba(255, 255, 255, .14); +} + +.viewer-source-error { + position: absolute; + z-index: 7; + bottom: max(86px, calc(env(safe-area-inset-bottom) + 80px)); + left: 50%; + min-height: 44px; + display: flex; + align-items: center; + gap: 9px; + padding: 4px 5px 4px 13px; + color: rgba(250, 251, 251, .88); + background: rgba(28, 30, 34, .84); + border: 1px solid rgba(255, 255, 255, .12); + border-radius: 7px; + box-shadow: 0 10px 28px rgba(0, 0, 0, .24); + transform: translateX(-50%); + font-size: 12px; + white-space: nowrap; +} + +.viewer-source-error button { + min-width: 64px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 10px; + color: #f7f9f8; + background: rgba(255, 255, 255, .1); + border: 0; + border-radius: 5px; +} + +.viewer-source-error button:hover { background: rgba(255, 255, 255, .16); } + +.image-viewer[data-controls-visible="false"] .viewer-source-error { + transform: translate(-50%, 8px); +} + +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.mobile-nav-layer { display: none; } + +@media (max-width: 1000px) { + :root { --sidebar-width: 214px; } + .content { padding-left: 14px; padding-right: 14px; } + .content-heading { align-items: flex-start; flex-direction: column; gap: 14px; } + .toolbar { width: 100%; } + .search-field { width: min(360px, 100%); flex: 1; } + .title-line h1 { max-width: 60vw; } + .skeleton-grid { grid-template-columns: repeat(auto-fill, minmax(218px, 1fr)); } +} + +@media (max-width: 720px) { + :root { --topbar-height: calc(56px + env(safe-area-inset-top)); } + .topbar { + height: var(--topbar-height); + padding: env(safe-area-inset-top) 12px 0; + gap: 8px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + .brand { min-width: 0; font-size: 16px; } + .brand-mark { width: 31px; height: 31px; } + .mobile-menu { width: 42px; height: 42px; display: grid; } + .desktop-sidebar { display: none; } + .content { + margin-left: 0; + padding: + calc(var(--topbar-height) + 14px) + max(8px, env(safe-area-inset-right)) + calc(28px + env(safe-area-inset-bottom)) + max(8px, env(safe-area-inset-left)); + } + .content-heading { gap: 11px; margin-bottom: 12px; } + .path-label { max-width: 86vw; } + .title-line h1 { max-width: 68vw; font-size: 22px; } + .toolbar { gap: 7px; } + .search-field, + .sort-control { height: 44px; border-radius: 9px; } + .search-field { width: auto; min-width: 0; } + .search-field input { font-size: 15px; } + .search-field input[type="search"]::-webkit-search-cancel-button { display: none; } + .sort-control { + position: relative; + width: 44px; + flex: 0 0 44px; + padding: 0; + justify-content: center; + } + .sort-control select { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + padding: 0; + opacity: 0; + } + .sort-control > svg { pointer-events: none; } + .masonry, + .masonry-column { gap: 4px; } + .image-card { + border-radius: 7px; + box-shadow: 0 1px 4px rgba(21, 31, 27, 0.08); + -webkit-tap-highlight-color: transparent; + } + .image-card:hover img { transform: none; } + .image-name { + inset: auto 0 0; + display: block; + padding: 20px 7px 6px; + color: rgba(255, 255, 255, .96); + background: linear-gradient(180deg, transparent, rgba(11, 15, 14, .78)); + border-radius: 0 0 6px 6px; + font-size: 10px; + opacity: 1; + text-align: left; + text-shadow: 0 1px 2px rgba(0, 0, 0, .4); + transform: none; + } + .skeleton-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; } + .status-compact { + min-width: 0; + max-width: 31vw; + padding: 6px 7px; + gap: 5px; + overflow: hidden; + font-size: 10px; + white-space: nowrap; + } + .status-compact span { overflow: hidden; text-overflow: ellipsis; } + .mobile-nav-layer { + position: fixed; + z-index: 80; + inset: 0; + display: block; + overscroll-behavior: contain; + } + .mobile-nav-scrim { + position: absolute; + inset: 0; + width: 100%; + padding: 0; + background: rgba(12, 16, 14, 0.5); + border: 0; + animation: mobile-scrim-in 180ms ease-out both; + } + .mobile-sidebar { + position: absolute; + inset: 0 auto 0 0; + width: min(286px, 86vw); + padding-top: env(safe-area-inset-top); + box-shadow: 12px 0 30px rgba(13, 21, 17, 0.18); + animation: mobile-drawer-in 220ms cubic-bezier(.22,.75,.18,1) both; + } + .mobile-sidebar .sidebar-progress { + padding-bottom: calc(17px + env(safe-area-inset-bottom)); + } + @keyframes mobile-scrim-in { from { opacity: 0; } to { opacity: 1; } } + @keyframes mobile-drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } } + .sidebar-close { display: grid; } + .viewer-topbar { + min-height: 82px; + padding: + max(13px, env(safe-area-inset-top)) + max(70px, calc(env(safe-area-inset-right) + 62px)) + 26px + max(14px, env(safe-area-inset-left)); + } + .viewer-about { gap: 10px; } + .viewer-counter { min-width: 48px; font-size: 12px; } + .viewer-file-copy strong { max-width: 50vw; font-size: 12px; } + .viewer-file-copy small { max-width: 52vw; font-size: 9px; } + .viewer-icon-button { width: 46px; height: 46px; flex-basis: 46px; } + .viewer-close { + top: max(11px, env(safe-area-inset-top)); + right: max(11px, env(safe-area-inset-right)); + width: 48px; + height: 48px; + } + .viewer-nav { display: none; } + .viewer-dock { + bottom: max(10px, calc(env(safe-area-inset-bottom) + 8px)); + gap: 2px; + } + .viewer-source-error { + bottom: max(78px, calc(env(safe-area-inset-bottom) + 72px)); + max-width: calc(100vw - 24px); + overflow: hidden; + } +} + +@media (max-width: 430px) { + .viewer-file-copy strong { max-width: 43vw; } + .viewer-file-copy small { max-width: 45vw; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..fad49bf --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,44 @@ +export interface Album { + path: string; + name: string; + count: number; +} + +export interface GallerySummary { + total: number; + albums: Album[]; +} + +export interface GalleryImage { + id: string; + name: string; + album: string; + relativePath: string; + width: number; + height: number; + size: number; + modifiedMs: number; + thumbnailUrl: string; + previewUrl: string; + originalUrl: string; +} + +export interface ImagesPage { + items: GalleryImage[]; + total: number; + offset: number; + limit: number; + nextOffset: number | null; +} + +export interface ThumbnailStatus { + total: number; + ready: number; + queued: number; + processing: number; + failed: number; + initialBatchReady: boolean; + backgroundComplete: boolean; +} + +export type SortMode = "name-asc" | "name-desc" | "newest"; diff --git a/frontend/src/viewer/useViewerEngine.ts b/frontend/src/viewer/useViewerEngine.ts new file mode 100644 index 0000000..9268534 --- /dev/null +++ b/frontend/src/viewer/useViewerEngine.ts @@ -0,0 +1,826 @@ +import { + type PointerEvent as ReactPointerEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +export interface Point { + x: number; + y: number; +} + +export interface Size { + width: number; + height: number; +} + +interface Motion extends Point { + scale: number; +} + +interface CanvasGeometry extends Point, Size {} + +type VisualUpdate = + | { kind: "motion"; motion: Motion } + | { kind: "track"; motion: Motion }; + +type GestureMode = "idle" | "pending" | "navigation" | "dismiss" | "pan" | "pinch"; + +interface Gesture { + mode: GestureMode; + pointerId: number; + pointerType: string; + start: Point; + last: Point; + lastTime: number; + velocity: Point; + origin: Motion; + pinchDistance: number; + pinchMidpoint: Point; + canvasCenter: Point; + moved: boolean; +} + +interface EngineOptions { + imageId: string; + frameSize: Size; + maximumZoom: number; + canPrevious: boolean; + canNext: boolean; + onNavigate: (direction: -1 | 1) => void; + onEdge: (direction: -1 | 1) => void; + onDismiss: () => void; + onTap: (point: Point, pointerType: string) => void; + onDragStart: () => void; + onInteraction: () => void; +} + +interface AnimateOptions { + duration?: number; + commitRaster?: boolean; + onComplete?: () => void; +} + +const EMPTY_GESTURE: Gesture = { + mode: "idle", + pointerId: -1, + pointerType: "mouse", + start: { x: 0, y: 0 }, + last: { x: 0, y: 0 }, + lastTime: 0, + velocity: { x: 0, y: 0 }, + origin: { x: 0, y: 0, scale: 1 }, + pinchDistance: 0, + pinchMidpoint: { x: 0, y: 0 }, + canvasCenter: { x: 0, y: 0 }, + moved: false, +}; + +const RASTER_PIXEL_BUDGET = 40_000_000; +const RASTER_EDGE_LIMIT = 8192; + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +function magnitude(point: Point): number { + return Math.hypot(point.x, point.y); +} + +function distance(first: Point, second: Point): number { + return Math.hypot(first.x - second.x, first.y - second.y); +} + +function midpoint(first: Point, second: Point): Point { + return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; +} + +function resist(value: number, limit: number, factor = 0.2): number { + if (value > limit) return limit + (value - limit) * factor; + if (value < -limit) return -limit + (value + limit) * factor; + return value; +} + +function easeOutQuart(value: number): number { + return 1 - (1 - value) ** 4; +} + +function reducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export function useViewerEngine(options: EngineOptions) { + const optionsRef = useRef(options); + optionsRef.current = options; + + const canvasRef = useRef(null); + const trackRef = useRef(null); + const motionRef = useRef(null); + const animationRef = useRef(0); + const trackAnimationRef = useRef(0); + const inputFrameRef = useRef(0); + const navigatingRef = useRef(false); + const navigationUnlockRef = useRef(0); + const rasterTimerRef = useRef(0); + const pointersRef = useRef(new Map()); + const gestureRef = useRef({ ...EMPTY_GESTURE }); + const layoutImageRef = useRef(options.imageId); + const currentRef = useRef({ x: 0, y: 0, scale: 1 }); + const targetRef = useRef({ x: 0, y: 0, scale: 1 }); + const trackMotionRef = useRef({ x: 0, y: 0, scale: 1 }); + const queuedVisualRef = useRef(null); + const geometryRef = useRef({ + x: 0, + y: 0, + width: window.innerWidth, + height: window.innerHeight, + }); + const rasterScaleRef = useRef(1); + const [settledScale, setSettledScale] = useState(1); + const [interacting, setInteracting] = useState(false); + + const captureGeometry = useCallback(() => { + const rect = canvasRef.current?.getBoundingClientRect(); + geometryRef.current = { + x: rect?.left ?? 0, + y: rect?.top ?? 0, + width: rect?.width ?? window.innerWidth, + height: rect?.height ?? window.innerHeight, + }; + }, []); + + const pageSpan = useCallback(() => { + const width = geometryRef.current.width; + return width + (width <= 720 ? 12 : 28); + }, []); + + const boundsFor = useCallback((scale: number) => { + const { frameSize } = optionsRef.current; + const { width, height } = geometryRef.current; + return { + x: Math.max(0, (frameSize.width * scale - width) / 2), + y: Math.max(0, (frameSize.height * scale - height) / 2), + }; + }, []); + + const constrain = useCallback((motion: Motion): Motion => { + const maximumZoom = Math.max(1, optionsRef.current.maximumZoom); + const scale = clamp(motion.scale, 1, maximumZoom); + const bounds = boundsFor(scale); + return { + x: clamp(motion.x, -bounds.x, bounds.x), + y: clamp(motion.y, -bounds.y, bounds.y), + scale, + }; + }, [boundsFor]); + + const applyMotion = useCallback((motion: Motion) => { + const element = motionRef.current; + if (!element) return; + const rasterScale = rasterScaleRef.current; + element.style.transform = `translate3d(${motion.x.toFixed(2)}px, ${motion.y.toFixed(2)}px, 0) scale(${(motion.scale / rasterScale).toFixed(4)})`; + element.dataset.zoomed = String(motion.scale > 1.01); + }, []); + + const commitRaster = useCallback(( + motion = currentRef.current, + settle = true, + displayedMotion = motion, + ) => { + const element = motionRef.current; + if (!element) return; + const { frameSize } = optionsRef.current; + const density = clamp(window.devicePixelRatio || 1, 1, 3); + const areaScale = Math.sqrt( + RASTER_PIXEL_BUDGET / + Math.max(frameSize.width * frameSize.height * density * density, 1), + ); + const edgeScale = Math.min( + RASTER_EDGE_LIMIT / Math.max(frameSize.width * density, 1), + RASTER_EDGE_LIMIT / Math.max(frameSize.height * density, 1), + ); + const maximumRasterScale = Math.max(1, Math.min(areaScale, edgeScale)); + const requestedRasterScale = motion.scale <= 1.01 + ? 1 + : Math.max(1, Math.min(motion.scale, maximumRasterScale)); + // Keep an already-promoted backing layer while zoomed. Dropping it on + // every zoom-out makes the browser resample a smaller bitmap and can + // soften the next quick pinch. The fit state still releases it. + const rasterScale = motion.scale <= 1.01 + ? 1 + : Math.max( + requestedRasterScale, + Math.min(rasterScaleRef.current, maximumRasterScale), + ); + rasterScaleRef.current = rasterScale; + const snap = (value: number) => Math.round(value * density) / density; + const width = snap(frameSize.width * rasterScale); + const height = snap(frameSize.height * rasterScale); + const left = snap((frameSize.width - width) / 2); + const top = snap((frameSize.height - height) / 2); + element.style.width = `${width.toFixed(2)}px`; + element.style.height = `${height.toFixed(2)}px`; + element.style.left = `${left.toFixed(2)}px`; + element.style.top = `${top.toFixed(2)}px`; + element.style.setProperty("--viewer-will-change", settle ? "auto" : "transform"); + element.dataset.scale = motion.scale.toFixed(4); + element.dataset.rasterScale = rasterScale.toFixed(4); + // A preheat can happen before an animation reaches its destination. Keep + // the currently displayed transform while changing the backing layer so + // promotion itself never causes a visible jump. + applyMotion(displayedMotion); + if (settle) setSettledScale(motion.scale); + }, [applyMotion]); + + // Keep a little headroom in the backing layer while the user is zooming. + // Rebuilding on every pointer event is expensive, so only promote it after + // a meaningful scale increase. Zooming out can keep the larger layer and + // therefore remains sharp without another allocation. + const prepareRaster = useCallback((motion: Motion) => { + const scale = Math.max(1, motion.scale); + const currentRaster = rasterScaleRef.current; + if (scale <= currentRaster * 1.25) return; + commitRaster(constrain(motion), false, currentRef.current); + }, [commitRaster, constrain]); + + const scheduleRaster = useCallback((delay = 100) => { + window.clearTimeout(rasterTimerRef.current); + rasterTimerRef.current = window.setTimeout(() => commitRaster(), delay); + }, [commitRaster]); + + const stopImageAnimation = useCallback(() => { + window.cancelAnimationFrame(animationRef.current); + animationRef.current = 0; + motionRef.current?.style.setProperty("--viewer-will-change", "auto"); + }, []); + + const setMotion = useCallback((motion: Motion) => { + currentRef.current = motion; + targetRef.current = motion; + applyMotion(motion); + }, [applyMotion]); + + const animateMotion = useCallback((destination: Motion, animation: AnimateOptions = {}) => { + stopImageAnimation(); + window.clearTimeout(rasterTimerRef.current); + const target = constrain(destination); + prepareRaster(target); + targetRef.current = target; + const duration = reducedMotion() ? 0 : (animation.duration ?? 240); + if (!duration) { + setMotion(target); + if (animation.commitRaster !== false) commitRaster(target); + animation.onComplete?.(); + return; + } + + motionRef.current?.style.setProperty("--viewer-will-change", "transform"); + const start = currentRef.current; + const startedAt = performance.now(); + const tick = (time: number) => { + const progress = clamp((time - startedAt) / duration, 0, 1); + const eased = easeOutQuart(progress); + const next = { + x: start.x + (target.x - start.x) * eased, + y: start.y + (target.y - start.y) * eased, + scale: start.scale + (target.scale - start.scale) * eased, + }; + currentRef.current = next; + applyMotion(next); + if (progress < 1) { + animationRef.current = window.requestAnimationFrame(tick); + return; + } + animationRef.current = 0; + currentRef.current = target; + applyMotion(target); + if (animation.commitRaster !== false) commitRaster(target); + animation.onComplete?.(); + }; + animationRef.current = window.requestAnimationFrame(tick); + }, [applyMotion, commitRaster, constrain, prepareRaster, setMotion, stopImageAnimation]); + + const applyTrack = useCallback((x: number, y = 0, scale = 1) => { + const canvas = canvasRef.current; + const progress = clamp( + Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1), + 0, + 1, + ); + trackMotionRef.current = { x, y, scale }; + const track = trackRef.current; + if (track) { + track.style.transform = `translate3d(${x.toFixed(2)}px, ${y.toFixed(2)}px, 0) scale(${scale.toFixed(4)})`; + } + if (canvas) { + canvas.style.setProperty("--viewer-dismiss-progress", progress.toFixed(4)); + } + }, []); + + const flushVisual = useCallback(() => { + window.cancelAnimationFrame(inputFrameRef.current); + inputFrameRef.current = 0; + const update = queuedVisualRef.current; + queuedVisualRef.current = null; + if (!update) return; + if (update.kind === "motion") applyMotion(update.motion); + else applyTrack(update.motion.x, update.motion.y, update.motion.scale); + }, [applyMotion, applyTrack]); + + const queueVisual = useCallback((update: VisualUpdate) => { + queuedVisualRef.current = update; + if (inputFrameRef.current) return; + inputFrameRef.current = window.requestAnimationFrame(() => { + inputFrameRef.current = 0; + const pending = queuedVisualRef.current; + queuedVisualRef.current = null; + if (!pending) return; + if (pending.kind === "motion") applyMotion(pending.motion); + else applyTrack(pending.motion.x, pending.motion.y, pending.motion.scale); + }); + }, [applyMotion, applyTrack]); + + const stopTrackAnimation = useCallback(() => { + window.cancelAnimationFrame(trackAnimationRef.current); + trackAnimationRef.current = 0; + trackRef.current?.style.setProperty("will-change", "auto"); + }, []); + + const animateTrack = useCallback(( + from: Point & { scale: number }, + to: Point & { scale: number }, + duration: number, + onComplete?: () => void, + ) => { + stopTrackAnimation(); + trackRef.current?.style.setProperty("will-change", "transform"); + const actualDuration = reducedMotion() ? 0 : duration; + if (!actualDuration) { + applyTrack(to.x, to.y, to.scale); + trackRef.current?.style.setProperty("will-change", "auto"); + onComplete?.(); + return; + } + const startedAt = performance.now(); + const tick = (time: number) => { + const progress = clamp((time - startedAt) / actualDuration, 0, 1); + const eased = easeOutQuart(progress); + applyTrack( + from.x + (to.x - from.x) * eased, + from.y + (to.y - from.y) * eased, + from.scale + (to.scale - from.scale) * eased, + ); + if (progress < 1) { + trackAnimationRef.current = window.requestAnimationFrame(tick); + return; + } + trackAnimationRef.current = 0; + trackRef.current?.style.setProperty("will-change", "auto"); + onComplete?.(); + }; + trackAnimationRef.current = window.requestAnimationFrame(tick); + }, [applyTrack, stopTrackAnimation]); + + const zoomAround = useCallback((base: Motion, scale: number, point: Point): Motion => { + const geometry = geometryRef.current; + const center = { + x: geometry.x + geometry.width / 2, + y: geometry.y + geometry.height / 2, + }; + const ratio = scale / Math.max(base.scale, 0.001); + return constrain({ + x: point.x - center.x - (point.x - center.x - base.x) * ratio, + y: point.y - center.y - (point.y - center.y - base.y) * ratio, + scale, + }); + }, [constrain]); + + const resetZoom = useCallback(() => { + optionsRef.current.onInteraction(); + animateMotion({ x: 0, y: 0, scale: 1 }, { duration: 180 }); + }, [animateMotion]); + + const zoomTo = useCallback((scale: number, point?: Point) => { + const maximum = Math.max(1, optionsRef.current.maximumZoom); + const nextScale = clamp(scale, 1, maximum); + const geometry = geometryRef.current; + const anchor = point ?? { + x: geometry.x + geometry.width / 2, + y: geometry.y + geometry.height / 2, + }; + optionsRef.current.onInteraction(); + animateMotion(zoomAround(currentRef.current, nextScale, anchor), { duration: 180 }); + }, [animateMotion, zoomAround]); + + const zoomBy = useCallback((factor: number) => { + zoomTo(currentRef.current.scale * factor); + }, [zoomTo]); + + const toggleZoomAt = useCallback((point: Point) => { + if (currentRef.current.scale > 1.08) { + resetZoom(); + return; + } + zoomTo(Math.min(4, Math.max(1, optionsRef.current.maximumZoom)), point); + }, [resetZoom, zoomTo]); + + const completeNavigation = useCallback((direction: -1 | 1) => { + const sourceId = optionsRef.current.imageId; + optionsRef.current.onNavigate(direction); + window.clearTimeout(navigationUnlockRef.current); + navigationUnlockRef.current = window.setTimeout(() => { + navigatingRef.current = false; + if (optionsRef.current.imageId === sourceId) applyTrack(0, 0, 1); + }, 500); + }, [applyTrack]); + + const navigate = useCallback((direction: -1 | 1) => { + if (navigatingRef.current) return false; + const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext; + if (!available) { + optionsRef.current.onEdge(direction); + return false; + } + optionsRef.current.onDragStart(); + flushVisual(); + navigatingRef.current = true; + const slide = () => { + const destination = -direction * pageSpan(); + animateTrack( + trackMotionRef.current, + { x: destination, y: 0, scale: 1 }, + 260, + () => completeNavigation(direction), + ); + }; + if (currentRef.current.scale > 1.01) { + animateMotion( + { x: 0, y: 0, scale: 1 }, + { duration: 160, onComplete: slide }, + ); + } else { + stopImageAnimation(); + slide(); + } + return true; + }, [animateMotion, animateTrack, completeNavigation, flushVisual, pageSpan, stopImageAnimation]); + + const beginPinch = useCallback(() => { + const pointers = [...pointersRef.current.values()]; + if (pointers.length < 2) return; + const first = pointers[0]; + const second = pointers[1]; + const geometry = geometryRef.current; + flushVisual(); + applyTrack(0, 0, 1); + optionsRef.current.onDragStart(); + gestureRef.current = { + ...EMPTY_GESTURE, + mode: "pinch", + origin: currentRef.current, + pinchDistance: Math.max(1, distance(first, second)), + pinchMidpoint: midpoint(first, second), + canvasCenter: { + x: geometry.x + geometry.width / 2, + y: geometry.y + geometry.height / 2, + }, + moved: true, + }; + setInteracting(true); + }, [applyTrack, flushVisual]); + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + if (navigatingRef.current) return; + event.preventDefault(); + captureGeometry(); + flushVisual(); + stopImageAnimation(); + stopTrackAnimation(); + window.clearTimeout(rasterTimerRef.current); + targetRef.current = currentRef.current; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // Synthetic events and older WebViews may not expose pointer capture. + } + const point = { x: event.clientX, y: event.clientY }; + pointersRef.current.set(event.pointerId, { ...point }); + optionsRef.current.onInteraction(); + + if (pointersRef.current.size >= 2) { + beginPinch(); + return; + } + gestureRef.current = { + ...EMPTY_GESTURE, + mode: "pending", + pointerId: event.pointerId, + pointerType: event.pointerType, + start: { ...point }, + last: { ...point }, + lastTime: performance.now(), + origin: currentRef.current, + }; + }, [beginPinch, captureGeometry, flushVisual, stopImageAnimation, stopTrackAnimation]); + + const handlePointerMove = useCallback((event: ReactPointerEvent) => { + const tracked = pointersRef.current.get(event.pointerId); + if (!tracked) return; + event.preventDefault(); + pointersRef.current.set(event.pointerId, { + x: event.clientX, + y: event.clientY, + }); + const gesture = gestureRef.current; + + if (gesture.mode === "pinch") { + const pointers = [...pointersRef.current.values()]; + if (pointers.length < 2) return; + const first = pointers[0]; + const second = pointers[1]; + const midpointNow = midpoint(first, second); + const rawScale = gesture.origin.scale * + (distance(first, second) / Math.max(gesture.pinchDistance, 1)); + const maximum = Math.max(1, optionsRef.current.maximumZoom); + const scale = rawScale < 1 + ? 1 - (1 - rawScale) * 0.22 + : rawScale > maximum + ? maximum + (rawScale - maximum) * 0.16 + : rawScale; + const center = gesture.canvasCenter; + const local = { + x: (gesture.pinchMidpoint.x - center.x - gesture.origin.x) / gesture.origin.scale, + y: (gesture.pinchMidpoint.y - center.y - gesture.origin.y) / gesture.origin.scale, + }; + const next = { + x: midpointNow.x - center.x - local.x * scale, + y: midpointNow.y - center.y - local.y * scale, + scale, + }; + currentRef.current = next; + targetRef.current = next; + prepareRaster(next); + motionRef.current?.style.setProperty("--viewer-will-change", "transform"); + queueVisual({ kind: "motion", motion: next }); + return; + } + + if (gesture.pointerId !== event.pointerId || gesture.mode === "idle") return; + const point = { x: event.clientX, y: event.clientY }; + const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y }; + const now = performance.now(); + const elapsed = Math.max(1, now - gesture.lastTime); + gesture.velocity = { + x: gesture.velocity.x * 0.62 + ((point.x - gesture.last.x) / elapsed) * 0.38, + y: gesture.velocity.y * 0.62 + ((point.y - gesture.last.y) / elapsed) * 0.38, + }; + gesture.last = point; + gesture.lastTime = now; + + if (gesture.mode === "pending" && magnitude(delta) >= (gesture.pointerType === "mouse" ? 5 : 9)) { + gesture.moved = true; + optionsRef.current.onDragStart(); + setInteracting(true); + if (gesture.origin.scale > 1.01) gesture.mode = "pan"; + else if (Math.abs(delta.x) > Math.abs(delta.y) * 1.08) gesture.mode = "navigation"; + else if (gesture.pointerType === "mouse") gesture.mode = "idle"; + else gesture.mode = "dismiss"; + } + + if (gesture.mode === "navigation") { + let x = delta.x; + if ((x > 0 && !optionsRef.current.canPrevious) || (x < 0 && !optionsRef.current.canNext)) { + x *= 0.22; + } + queueVisual({ kind: "track", motion: { x, y: 0, scale: 1 } }); + return; + } + + if (gesture.mode === "dismiss") { + const y = delta.y >= 0 ? delta.y * 0.94 : delta.y * 0.16; + const progress = clamp(Math.max(0, y) / Math.max(geometryRef.current.height * 0.7, 1), 0, 1); + queueVisual({ + kind: "track", + motion: { x: delta.x * 0.08, y, scale: 1 - progress * 0.055 }, + }); + return; + } + + if (gesture.mode === "pan") { + const scale = gesture.origin.scale; + const bounds = boundsFor(scale); + const next = { + x: resist(gesture.origin.x + delta.x, bounds.x), + y: resist(gesture.origin.y + delta.y, bounds.y), + scale, + }; + currentRef.current = next; + targetRef.current = next; + motionRef.current?.style.setProperty("--viewer-will-change", "transform"); + queueVisual({ kind: "motion", motion: next }); + } + }, [boundsFor, prepareRaster, queueVisual]); + + const finishPointer = useCallback((event: ReactPointerEvent, cancelled: boolean) => { + const gesture = gestureRef.current; + const tracked = pointersRef.current.has(event.pointerId); + pointersRef.current.delete(event.pointerId); + if (!tracked) return; + flushVisual(); + + if (gesture.mode === "pinch") { + const remaining = [...pointersRef.current.entries()]; + if (remaining.length === 1) { + window.clearTimeout(rasterTimerRef.current); + const [pointerId, point] = remaining[0]; + gestureRef.current = { + ...EMPTY_GESTURE, + mode: "pan", + pointerId, + pointerType: "touch", + start: { ...point }, + last: { ...point }, + lastTime: performance.now(), + origin: currentRef.current, + moved: true, + }; + return; + } + const target = constrain(currentRef.current); + animateMotion(target, { duration: 220 }); + gestureRef.current = { ...EMPTY_GESTURE }; + setInteracting(false); + return; + } + + if (gesture.pointerId !== event.pointerId) return; + const endPoint = cancelled + ? gesture.last + : { x: event.clientX, y: event.clientY }; + const delta = { + x: endPoint.x - gesture.start.x, + y: endPoint.y - gesture.start.y, + }; + const displayedTrack = trackMotionRef.current; + let keepInteracting = false; + if (cancelled && gesture.mode === "pan") { + animateMotion(constrain(currentRef.current), { duration: 180 }); + } else if (cancelled) { + animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 180); + } else if (gesture.mode === "navigation") { + const width = geometryRef.current.width; + const direction: -1 | 1 = delta.x > 0 ? -1 : 1; + const available = direction < 0 ? optionsRef.current.canPrevious : optionsRef.current.canNext; + const commit = available && + (Math.abs(delta.x) > Math.min(120, width * 0.2) || Math.abs(gesture.velocity.x) > 0.55); + if (commit) { + navigatingRef.current = true; + const destination = -direction * pageSpan(); + animateTrack( + displayedTrack, + { x: destination, y: 0, scale: 1 }, + 230, + () => completeNavigation(direction), + ); + } else { + if (!available) optionsRef.current.onEdge(direction); + animateTrack(displayedTrack, { x: 0, y: 0, scale: 1 }, 210); + } + } else if (gesture.mode === "dismiss") { + const height = geometryRef.current.height; + const commit = delta.y > Math.min(150, height * 0.18) || + (delta.y > 50 && gesture.velocity.y > 0.65); + if (commit) { + navigatingRef.current = true; + keepInteracting = true; + animateTrack( + displayedTrack, + { x: delta.x * 0.12, y: height * 1.08, scale: 0.92 }, + 240, + optionsRef.current.onDismiss, + ); + } else { + animateTrack( + displayedTrack, + { x: 0, y: 0, scale: 1 }, + 220, + ); + } + } else if (gesture.mode === "pan") { + const inertial = { + x: currentRef.current.x + clamp(gesture.velocity.x * 130, -180, 180), + y: currentRef.current.y + clamp(gesture.velocity.y * 130, -180, 180), + scale: currentRef.current.scale, + }; + animateMotion(constrain(inertial), { duration: 260 }); + } else if (gesture.mode === "pending" && !gesture.moved) { + optionsRef.current.onTap(endPoint, gesture.pointerType); + } + + gestureRef.current = { ...EMPTY_GESTURE }; + if (!keepInteracting) setInteracting(false); + }, [animateMotion, animateTrack, completeNavigation, constrain, flushVisual, pageSpan]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + if (optionsRef.current.maximumZoom <= 1.01) return; + optionsRef.current.onInteraction(); + stopImageAnimation(); + const delta = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 18 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * geometryRef.current.height + : event.deltaY; + targetRef.current = currentRef.current; + const base = currentRef.current; + const scale = clamp( + base.scale * Math.exp(-delta * 0.00135), + 1, + Math.max(1, optionsRef.current.maximumZoom), + ); + const target = zoomAround(base, scale, { x: event.clientX, y: event.clientY }); + const motionReduced = reducedMotion(); + animateMotion(target, { duration: 90, commitRaster: motionReduced }); + if (!motionReduced) scheduleRaster(120); + }; + canvas.addEventListener("wheel", handleWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", handleWheel); + }, [animateMotion, scheduleRaster, stopImageAnimation, zoomAround]); + + useEffect(() => { + const update = () => captureGeometry(); + window.addEventListener("resize", update); + window.visualViewport?.addEventListener("resize", update); + return () => { + window.removeEventListener("resize", update); + window.visualViewport?.removeEventListener("resize", update); + }; + }, [captureGeometry]); + + useLayoutEffect(() => { + captureGeometry(); + flushVisual(); + stopImageAnimation(); + stopTrackAnimation(); + window.clearTimeout(rasterTimerRef.current); + window.clearTimeout(navigationUnlockRef.current); + pointersRef.current.clear(); + gestureRef.current = { ...EMPTY_GESTURE }; + if (layoutImageRef.current !== options.imageId) { + layoutImageRef.current = options.imageId; + currentRef.current = { x: 0, y: 0, scale: 1 }; + targetRef.current = { x: 0, y: 0, scale: 1 }; + rasterScaleRef.current = 1; + } else { + const motion = constrain(currentRef.current); + currentRef.current = motion; + targetRef.current = motion; + } + navigatingRef.current = false; + applyTrack(0, 0, 1); + commitRaster(currentRef.current); + setInteracting(false); + }, [options.imageId, options.frameSize.height, options.frameSize.width, applyTrack, captureGeometry, commitRaster, constrain, flushVisual, stopImageAnimation, stopTrackAnimation]); + + useEffect(() => { + if (currentRef.current.scale <= options.maximumZoom + 0.001) return; + animateMotion(constrain(currentRef.current)); + }, [options.maximumZoom, animateMotion, constrain]); + + useEffect(() => () => { + window.cancelAnimationFrame(animationRef.current); + window.cancelAnimationFrame(trackAnimationRef.current); + window.cancelAnimationFrame(inputFrameRef.current); + window.clearTimeout(rasterTimerRef.current); + window.clearTimeout(navigationUnlockRef.current); + }, []); + + return { + canvasRef, + trackRef, + motionRef, + settledScale, + interacting, + canZoom: options.maximumZoom > 1.01, + handlePointerDown, + handlePointerMove, + handlePointerUp: (event: ReactPointerEvent) => finishPointer(event, false), + handlePointerCancel: (event: ReactPointerEvent) => finishPointer(event, true), + handleLostPointerCapture: (event: ReactPointerEvent) => finishPointer(event, true), + navigate, + resetZoom, + toggleZoomAt, + zoomBy, + }; +} + +export type ViewerEngine = ReturnType; +export type ViewerElementRef = RefObject; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..369be0a --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c012dd --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,5 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ef07d13 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + target: "es2022", + outDir: "dist", + emptyOutDir: true, + cssCodeSplit: false, + rollupOptions: { + output: { + entryFileNames: "assets/app.js", + chunkFileNames: "assets/[name].js", + assetFileNames: (asset) => + asset.name?.endsWith(".css") ? "assets/app.css" : "assets/[name][extname]", + }, + }, + }, +}); + diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a8904aa --- /dev/null +++ b/src/config.rs @@ -0,0 +1,125 @@ +use std::{env, net::SocketAddr, path::PathBuf, time::Duration}; + +use anyhow::{Context, Result, bail}; + +#[derive(Clone, Debug)] +pub struct Config { + pub gallery_dir: PathBuf, + pub cache_dir: PathBuf, + pub listen: SocketAddr, + pub initial_batch: usize, + pub workers: usize, + pub scan_interval: Duration, +} + +impl Config { + pub fn from_env_and_args() -> Result { + let cwd = env::current_dir().context("cannot determine the working directory")?; + let mut gallery_dir = cwd.join("pic"); + let mut cache_dir = cwd.join(".pixhelf-cache").join("thumbnails"); + let mut listen = "0.0.0.0:3002".parse::()?; + let mut initial_batch = 60usize; + let mut workers = default_worker_count(); + let mut scan_interval = Duration::from_secs(10); + + let mut args = env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--gallery-dir" => gallery_dir = path_value(&mut args, &arg, &cwd)?, + "--cache-dir" => cache_dir = path_value(&mut args, &arg, &cwd)?, + "--listen" => { + listen = value(&mut args, &arg)? + .parse() + .with_context(|| "--listen must be in HOST:PORT form")?; + } + "--initial-batch" => { + initial_batch = number_value(&mut args, &arg, 1, 500)?; + } + "--workers" => workers = number_value(&mut args, &arg, 1, 16)?, + "--scan-interval" => { + scan_interval = + Duration::from_secs(number_value(&mut args, &arg, 2, 3600)? as u64); + } + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + unknown => bail!("unknown argument: {unknown}\n\nRun pixhelf --help for usage."), + } + } + + if !gallery_dir.is_dir() { + bail!( + "gallery directory does not exist: {}", + gallery_dir.display() + ); + } + + std::fs::create_dir_all(&cache_dir) + .with_context(|| format!("cannot create cache directory: {}", cache_dir.display()))?; + + Ok(Self { + gallery_dir, + cache_dir, + listen, + initial_batch, + workers, + scan_interval, + }) + } +} + +fn value(args: &mut impl Iterator, flag: &str) -> Result { + args.next() + .with_context(|| format!("{flag} requires a value")) +} + +fn path_value( + args: &mut impl Iterator, + flag: &str, + cwd: &std::path::Path, +) -> Result { + let path = PathBuf::from(value(args, flag)?); + Ok(if path.is_absolute() { + path + } else { + cwd.join(path) + }) +} + +fn number_value( + args: &mut impl Iterator, + flag: &str, + min: usize, + max: usize, +) -> Result { + let raw = value(args, flag)?; + let parsed = raw + .parse::() + .with_context(|| format!("{flag} must be a number"))?; + if !(min..=max).contains(&parsed) { + bail!("{flag} must be between {min} and {max}"); + } + Ok(parsed) +} + +fn default_worker_count() -> usize { + std::thread::available_parallelism() + .map(|parallelism| parallelism.get().clamp(2, 4)) + .unwrap_or(2) +} + +fn print_help() { + println!( + "pixhelf - a self-hosted image gallery\n\n\ +Usage: pixhelf [OPTIONS]\n\n\ +Options:\n \ + --gallery-dir PATH Gallery root (default: ./pic)\n \ + --cache-dir PATH Thumbnail and viewer preview cache (default: ./.pixhelf-cache/thumbnails)\n \ + --listen HOST:PORT Listen address (default: 0.0.0.0:3002)\n \ + --initial-batch N Thumbnails prepared before serving (default: 60)\n \ + --workers N Background thumbnail workers (default: 2-4, based on CPU)\n \ + --scan-interval SEC Gallery rescan interval (default: 10)\n \ + -h, --help Show this help" + ); +} diff --git a/src/gallery.rs b/src/gallery.rs new file mode 100644 index 0000000..759b04c --- /dev/null +++ b/src/gallery.rs @@ -0,0 +1,259 @@ +use std::{ + collections::{BTreeMap, HashMap}, + fs, + path::{Path, PathBuf}, + sync::Arc, + time::UNIX_EPOCH, +}; + +use anyhow::{Context, Result}; +use image::{ImageDecoder, ImageReader, metadata::Orientation}; +use serde::Serialize; +use tracing::warn; +use walkdir::WalkDir; + +#[derive(Clone, Debug)] +pub struct ImageRecord { + pub id: String, + pub path: PathBuf, + pub relative_path: String, + pub name: String, + pub album: String, + pub width: u32, + pub height: u32, + pub size: u64, + pub modified_ms: u64, + pub(crate) modified_ns: u128, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Album { + pub path: String, + pub name: String, + pub count: usize, +} + +#[derive(Clone, Default)] +pub struct GalleryIndex { + pub images: Vec>, + pub by_id: HashMap>, + pub albums: Vec, +} + +impl GalleryIndex { + pub fn get(&self, id: &str) -> Option> { + self.by_id.get(id).cloned() + } + + pub fn same_revision(&self, other: &Self) -> bool { + self.images.len() == other.images.len() + && self + .images + .iter() + .zip(&other.images) + .all(|(left, right)| left.id == right.id) + } +} + +pub fn scan_gallery(root: &Path, previous: Option<&GalleryIndex>) -> Result { + let previous_by_path: HashMap<&str, &Arc> = previous + .into_iter() + .flat_map(|index| index.images.iter()) + .map(|record| (record.relative_path.as_str(), record)) + .collect(); + + let mut images = Vec::new(); + for entry in WalkDir::new(root) + .follow_links(false) + .sort_by_file_name() + .into_iter() + .filter_map(|entry| match entry { + Ok(entry) => Some(entry), + Err(error) => { + warn!(%error, "cannot inspect gallery entry"); + None + } + }) + { + if !entry.file_type().is_file() || !is_supported_image(entry.path()) { + continue; + } + + let path = entry.into_path(); + let relative = path + .strip_prefix(root) + .with_context(|| format!("{} is outside the gallery root", path.display()))?; + let relative_path = path_to_url(relative); + let metadata = match fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + warn!(path = %path.display(), %error, "cannot read image metadata"); + continue; + } + }; + let modified = metadata + .modified() + .unwrap_or(UNIX_EPOCH) + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let modified_ns = modified.as_nanos(); + let size = metadata.len(); + + if let Some(existing) = previous_by_path.get(relative_path.as_str()) + && existing.size == size + && existing.modified_ns == modified_ns + { + images.push(Arc::clone(existing)); + continue; + } + + let (width, height) = match probe_dimensions(&path) { + Ok(dimensions) => dimensions, + Err(error) => { + warn!(path = %path.display(), %error, "cannot decode image header"); + continue; + } + }; + let album = relative + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(path_to_url) + .unwrap_or_default(); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&relative_path) + .to_owned(); + let id = image_id(&relative_path, size, modified_ns); + + images.push(Arc::new(ImageRecord { + id, + path, + relative_path, + name, + album, + width, + height, + size, + modified_ms: modified.as_millis().min(u64::MAX as u128) as u64, + modified_ns, + })); + } + + images.sort_by(|left, right| { + natord::compare_ignore_case(&left.relative_path, &right.relative_path) + }); + + let mut album_counts = BTreeMap::::new(); + for image in &images { + *album_counts.entry(image.album.clone()).or_default() += 1; + } + let albums = album_counts + .into_iter() + .map(|(path, count)| Album { + name: path + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("根目录") + .to_owned(), + path, + count, + }) + .collect(); + let by_id = images + .iter() + .map(|record| (record.id.clone(), Arc::clone(record))) + .collect(); + + Ok(GalleryIndex { + images, + by_id, + albums, + }) +} + +fn probe_dimensions(path: &Path) -> Result<(u32, u32)> { + let reader = ImageReader::open(path)?.with_guessed_format()?; + let mut decoder = reader.into_decoder()?; + let (width, height) = decoder.dimensions(); + let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); + Ok(oriented_dimensions(width, height, orientation)) +} + +fn oriented_dimensions(width: u32, height: u32, orientation: Orientation) -> (u32, u32) { + match orientation { + Orientation::Rotate90 + | Orientation::Rotate270 + | Orientation::Rotate90FlipH + | Orientation::Rotate270FlipH => (height, width), + _ => (width, height), + } +} + +fn is_supported_image(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "jpg" | "jpeg" | "png" | "webp" + ) + }) + .unwrap_or(false) +} + +fn image_id(relative_path: &str, size: u64, modified_ns: u128) -> String { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"pixhelf-image-v1\0"); + hasher.update(relative_path.as_bytes()); + hasher.update(&size.to_le_bytes()); + hasher.update(&modified_ns.to_le_bytes()); + hasher.finalize().to_hex().to_string() +} + +fn path_to_url(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn swaps_dimensions_for_quarter_turns() { + assert_eq!( + oriented_dimensions(1200, 800, Orientation::Rotate90), + (800, 1200) + ); + assert_eq!( + oriented_dimensions(1200, 800, Orientation::Rotate270FlipH), + (800, 1200) + ); + assert_eq!( + oriented_dimensions(1200, 800, Orientation::Rotate180), + (1200, 800) + ); + } + + #[test] + fn scans_images_and_skips_other_files() { + let temp = tempfile::tempdir().unwrap(); + let album = temp.path().join("album"); + fs::create_dir(&album).unwrap(); + image::RgbImage::new(20, 10) + .save(album.join("image.png")) + .unwrap(); + fs::write(album.join("notes.tsv"), "ignored").unwrap(); + + let index = scan_gallery(temp.path(), None).unwrap(); + assert_eq!(index.images.len(), 1); + assert_eq!(index.images[0].album, "album"); + assert_eq!((index.images[0].width, index.images[0].height), (20, 10)); + assert_eq!(index.albums[0].count, 1); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f268fe7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,133 @@ +mod config; +mod gallery; +mod thumbs; +mod web; + +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use config::Config; +use gallery::scan_gallery; +use thumbs::{ThumbnailManager, ViewerPreviewManager}; +use tokio::{net::TcpListener, sync::RwLock}; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; +use web::AppState; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("pixhelf=info,tower_http=info")), + ) + .compact() + .init(); + + let config = Config::from_env_and_args()?; + info!(gallery = %config.gallery_dir.display(), "scanning gallery"); + let gallery_dir = config.gallery_dir.clone(); + let index = tokio::task::spawn_blocking(move || scan_gallery(&gallery_dir, None)) + .await + .context("gallery scan task stopped")??; + if index.images.is_empty() { + bail!( + "no supported images found in {}", + config.gallery_dir.display() + ); + } + info!( + images = index.images.len(), + albums = index.albums.len(), + "gallery indexed" + ); + + let initial_ids: Vec = index + .images + .iter() + .take(config.initial_batch) + .map(|record| record.id.clone()) + .collect(); + let index = Arc::new(RwLock::new(index)); + let thumbnails = ThumbnailManager::new(config.cache_dir.clone())?; + let previews = ViewerPreviewManager::new(config.cache_dir.clone())?; + { + let current = index.read().await; + thumbnails.reconcile(¤t.images); + previews.reconcile(¤t.images); + } + thumbnails.start_workers(config.workers); + let initial_thumbnail_manager = Arc::clone(&thumbnails); + tokio::spawn(async move { + initial_thumbnail_manager + .prepare_initial(&initial_ids) + .await; + }); + + let state = AppState { + index: Arc::clone(&index), + thumbnails: Arc::clone(&thumbnails), + previews: Arc::clone(&previews), + }; + let cleanup_manager = Arc::clone(&thumbnails); + tokio::spawn(async move { + cleanup_manager.cleanup_stale().await; + }); + let preview_cleanup_manager = Arc::clone(&previews); + tokio::spawn(async move { + preview_cleanup_manager.cleanup_stale().await; + }); + tokio::spawn(rescan_gallery(state.clone(), config.clone())); + + let listener = TcpListener::bind(config.listen) + .await + .with_context(|| format!("cannot listen on {}", config.listen))?; + info!(listen = %config.listen, "Pixhelf is ready"); + + axum::serve(listener, web::router(state)) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn rescan_gallery(state: AppState, config: Config) { + loop { + tokio::time::sleep(config.scan_interval).await; + let previous = state.index.read().await.clone(); + let root = config.gallery_dir.clone(); + let previous_for_scan = previous.clone(); + let scan = + tokio::task::spawn_blocking(move || scan_gallery(&root, Some(&previous_for_scan))) + .await; + + let updated = match scan { + Ok(Ok(index)) => index, + Ok(Err(error)) => { + error!(%error, "gallery rescan failed"); + continue; + } + Err(error) => { + error!(%error, "gallery rescan task stopped"); + continue; + } + }; + if previous.same_revision(&updated) { + continue; + } + + let old_count = previous.images.len(); + let new_count = updated.images.len(); + state.thumbnails.reconcile(&updated.images); + state.previews.reconcile(&updated.images); + *state.index.write().await = updated; + state.thumbnails.cleanup_stale().await; + state.previews.cleanup_stale().await; + info!(old_count, new_count, "gallery changes indexed"); + } +} + +async fn shutdown_signal() { + if let Err(error) = tokio::signal::ctrl_c().await { + error!(%error, "cannot install shutdown handler"); + } +} diff --git a/src/thumbs.rs b/src/thumbs.rs new file mode 100644 index 0000000..3e8a8e6 --- /dev/null +++ b/src/thumbs.rs @@ -0,0 +1,757 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, RwLock, + atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}, + }, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use image::{ + DynamicImage, GenericImageView, ImageDecoder, ImageReader, imageops::FilterType, + metadata::Orientation, +}; +use serde::Serialize; +use tokio::sync::{Notify, Semaphore}; +use tracing::{info, warn}; +use walkdir::WalkDir; + +use crate::gallery::ImageRecord; + +const THUMBNAIL_EDGE: u32 = 720; +const WEBP_QUALITY: f32 = 82.0; +const CACHE_VERSION: &str = "720-webp-q82-v1"; +const THUMBNAIL_FILTER: FilterType = FilterType::Triangle; +const MAX_ATTEMPTS: u8 = 3; + +const PENDING: u8 = 0; +const PROCESSING: u8 = 1; +const READY: u8 = 2; +const FAILED: u8 = 3; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub struct ThumbnailManager { + cache_root: PathBuf, + entries: RwLock>>, + queue: JobQueue, + initial_ready: AtomicBool, +} + +const PREVIEW_CACHE_VERSION: &str = "2560-webp-q88-v2"; +const PREVIEW_EDGE: u32 = 2560; +const PREVIEW_QUALITY: f32 = 88.0; +const PREVIEW_FILTER: FilterType = FilterType::CatmullRom; +const PREVIEW_PENDING: u8 = 0; +const PREVIEW_PROCESSING: u8 = 1; +const PREVIEW_READY: u8 = 2; +const PREVIEW_FAILED: u8 = 3; + +/// Viewer previews are intentionally independent from the eager thumbnail queue. +/// They are generated only when a user opens an image, then retained immutably. +pub struct ViewerPreviewManager { + cache_root: PathBuf, + entries: RwLock>>, + slots: Arc, +} + +struct PreviewEntry { + record: Arc, + cache_path: PathBuf, + state: AtomicU8, + last_error: Mutex>, + notify: Notify, +} + +impl ViewerPreviewManager { + pub fn new(cache_dir: PathBuf) -> Result> { + let cache_root = cache_dir.join(PREVIEW_CACHE_VERSION); + fs::create_dir_all(&cache_root).with_context(|| { + format!( + "cannot create viewer preview cache: {}", + cache_root.display() + ) + })?; + purge_temporary_files(&cache_root); + Ok(Arc::new(Self { + cache_root, + entries: RwLock::new(HashMap::new()), + slots: Arc::new(Semaphore::new(2)), + })) + } + + pub fn reconcile(&self, records: &[Arc]) { + let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect(); + let mut entries = self.entries.write().expect("viewer preview entries lock"); + entries.retain(|id, _| current_ids.contains(id.as_str())); + for record in records { + if entries.contains_key(&record.id) { + continue; + } + let cache_path = shard_path(&self.cache_root, &record.id); + let state = if valid_cache_file(&cache_path) { + PREVIEW_READY + } else { + PREVIEW_PENDING + }; + entries.insert( + record.id.clone(), + Arc::new(PreviewEntry { + record: Arc::clone(record), + cache_path, + state: AtomicU8::new(state), + last_error: Mutex::new(None), + notify: Notify::new(), + }), + ); + } + } + + pub async fn ensure_ready(&self, id: &str) -> Result { + let entry = self + .entries + .read() + .expect("viewer preview entries lock") + .get(id) + .cloned() + .with_context(|| format!("unknown image: {id}"))?; + + loop { + let notified = entry.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + match entry.state.load(Ordering::Acquire) { + PREVIEW_READY if valid_cache_file(&entry.cache_path) => { + return Ok(entry.cache_path.clone()); + } + PREVIEW_READY => { + let _ = entry.state.compare_exchange( + PREVIEW_READY, + PREVIEW_PENDING, + Ordering::AcqRel, + Ordering::Acquire, + ); + } + PREVIEW_PENDING => { + if entry + .state + .compare_exchange( + PREVIEW_PENDING, + PREVIEW_PROCESSING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + self.start_generation(Arc::clone(&entry)); + } + } + PREVIEW_PROCESSING => notified.as_mut().await, + PREVIEW_FAILED => { + let message = entry + .last_error + .lock() + .expect("viewer preview error lock") + .clone() + .unwrap_or_else(|| "viewer preview generation failed".to_owned()); + bail!(message); + } + _ => unreachable!(), + } + } + } + + fn start_generation(&self, entry: Arc) { + let slots = Arc::clone(&self.slots); + tokio::spawn(async move { + let record = Arc::clone(&entry.record); + let output = entry.cache_path.clone(); + let result = async move { + let _permit = slots + .acquire_owned() + .await + .map_err(|_| anyhow!("viewer preview worker stopped"))?; + tokio::task::spawn_blocking(move || generate_preview(&record, &output)) + .await + .map_err(|error| anyhow!("viewer preview worker stopped: {error}"))? + } + .await; + + match result { + Ok(()) => { + entry.state.store(PREVIEW_READY, Ordering::Release); + entry.notify.notify_waiters(); + } + Err(error) => { + *entry.last_error.lock().expect("viewer preview error lock") = + Some(error.to_string()); + entry.state.store(PREVIEW_FAILED, Ordering::Release); + entry.notify.notify_waiters(); + warn!(image = %entry.record.relative_path, %error, "viewer preview generation failed"); + } + } + }); + } + + pub async fn cleanup_stale(&self) { + let valid_ids: HashSet = self + .entries + .read() + .expect("viewer preview entries lock") + .keys() + .cloned() + .collect(); + let root = self.cache_root.clone(); + if let Err(error) = + tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await + { + warn!(%error, "viewer preview cache cleanup task failed"); + } + } +} + +struct ThumbEntry { + record: Arc, + cache_path: PathBuf, + state: AtomicU8, + urgent: AtomicBool, + attempts: AtomicU8, + last_error: Mutex>, + notify: Notify, +} + +#[derive(Default)] +struct JobQueue { + state: Mutex, + notify: Notify, +} + +#[derive(Default)] +struct QueueState { + urgent: VecDeque, + background: VecDeque, + queued: HashSet, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThumbnailStatus { + pub total: usize, + pub ready: usize, + pub queued: usize, + pub processing: usize, + pub failed: usize, + pub initial_batch_ready: bool, + pub background_complete: bool, +} + +impl ThumbnailManager { + pub fn new(cache_dir: PathBuf) -> Result> { + let cache_root = cache_dir.join(CACHE_VERSION); + fs::create_dir_all(&cache_root) + .with_context(|| format!("cannot create thumbnail cache: {}", cache_root.display()))?; + purge_temporary_files(&cache_root); + Ok(Arc::new(Self { + cache_root, + entries: RwLock::new(HashMap::new()), + queue: JobQueue::default(), + initial_ready: AtomicBool::new(false), + })) + } + + pub fn reconcile(&self, records: &[Arc]) { + let current_ids: HashSet<&str> = records.iter().map(|record| record.id.as_str()).collect(); + let mut missing = Vec::new(); + { + let mut entries = self.entries.write().expect("thumbnail entries lock"); + entries.retain(|id, _| current_ids.contains(id.as_str())); + + for record in records { + if entries.contains_key(&record.id) { + continue; + } + let cache_path = self.cache_path(&record.id); + let state = if valid_cache_file(&cache_path) { + READY + } else { + missing.push(record.id.clone()); + PENDING + }; + entries.insert( + record.id.clone(), + Arc::new(ThumbEntry { + record: Arc::clone(record), + cache_path, + state: AtomicU8::new(state), + urgent: AtomicBool::new(false), + attempts: AtomicU8::new(0), + last_error: Mutex::new(None), + notify: Notify::new(), + }), + ); + } + } + + for id in missing { + self.queue.push_background(id); + } + } + + pub fn start_workers(self: &Arc, count: usize) { + for worker in 0..count { + let manager = Arc::clone(self); + tokio::spawn(async move { + manager.worker_loop(worker).await; + }); + } + } + + pub async fn prepare_initial(&self, ids: &[String]) { + for id in ids { + self.promote(id); + } + + let mut failures = 0usize; + for id in ids { + if let Err(error) = self.wait_for(id).await { + failures += 1; + warn!(%id, %error, "initial thumbnail failed"); + } + } + self.initial_ready.store(true, Ordering::Release); + info!( + requested = ids.len(), + failures, "initial thumbnail batch is ready" + ); + } + + pub async fn ensure_ready(&self, id: &str) -> Result { + self.promote(id); + self.wait_for(id).await + } + + pub fn status(&self) -> ThumbnailStatus { + let entries = self.entries.read().expect("thumbnail entries lock"); + let mut ready = 0; + let mut queued = 0; + let mut processing = 0; + let mut failed = 0; + + for entry in entries.values() { + match entry.state.load(Ordering::Acquire) { + READY => ready += 1, + PROCESSING => processing += 1, + FAILED => failed += 1, + _ => queued += 1, + } + } + + let total = entries.len(); + ThumbnailStatus { + total, + ready, + queued, + processing, + failed, + initial_batch_ready: self.initial_ready.load(Ordering::Acquire), + background_complete: ready + failed == total && processing == 0, + } + } + + pub async fn cleanup_stale(&self) { + let valid_ids: HashSet = self + .entries + .read() + .expect("thumbnail entries lock") + .keys() + .cloned() + .collect(); + let root = self.cache_root.clone(); + if let Err(error) = + tokio::task::spawn_blocking(move || cleanup_cache(&root, &valid_ids)).await + { + warn!(%error, "thumbnail cache cleanup task failed"); + } + } + + fn promote(&self, id: &str) { + let entry = self + .entries + .read() + .expect("thumbnail entries lock") + .get(id) + .cloned(); + if let Some(entry) = entry + && entry.state.load(Ordering::Acquire) == PENDING + { + entry.urgent.store(true, Ordering::Release); + self.queue.push_urgent(id.to_owned()); + } + } + + async fn wait_for(&self, id: &str) -> Result { + let entry = self + .entries + .read() + .expect("thumbnail entries lock") + .get(id) + .cloned() + .with_context(|| format!("unknown image: {id}"))?; + + loop { + let notified = entry.notify.notified(); + match entry.state.load(Ordering::Acquire) { + READY if valid_cache_file(&entry.cache_path) => { + return Ok(entry.cache_path.clone()); + } + READY => { + if entry + .state + .compare_exchange(READY, PENDING, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.queue.push_urgent(id.to_owned()); + } + } + FAILED => { + let message = entry + .last_error + .lock() + .expect("thumbnail error lock") + .clone() + .unwrap_or_else(|| "thumbnail generation failed".to_owned()); + bail!(message); + } + _ => notified.await, + } + } + } + + async fn worker_loop(self: Arc, worker: usize) { + loop { + let id = self.queue.pop().await; + let entry = self + .entries + .read() + .expect("thumbnail entries lock") + .get(&id) + .cloned(); + let Some(entry) = entry else { + continue; + }; + if entry + .state + .compare_exchange(PENDING, PROCESSING, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + continue; + } + + let record = Arc::clone(&entry.record); + let output = entry.cache_path.clone(); + let result = tokio::task::spawn_blocking(move || generate_thumbnail(&record, &output)) + .await + .map_err(|error| anyhow!("thumbnail worker stopped: {error}")) + .and_then(|result| result); + + match result { + Ok(()) => { + entry.state.store(READY, Ordering::Release); + entry.notify.notify_waiters(); + } + Err(error) => { + let attempt = entry.attempts.fetch_add(1, Ordering::AcqRel) + 1; + *entry.last_error.lock().expect("thumbnail error lock") = + Some(error.to_string()); + warn!(worker, image = %entry.record.relative_path, attempt, %error, "thumbnail generation failed"); + + if attempt < MAX_ATTEMPTS { + entry.state.store(PENDING, Ordering::Release); + entry.notify.notify_waiters(); + self.schedule_retry(entry, id, attempt); + } else { + entry.state.store(FAILED, Ordering::Release); + entry.notify.notify_waiters(); + } + } + } + } + } + + fn cache_path(&self, id: &str) -> PathBuf { + shard_path(&self.cache_root, id) + } + + fn schedule_retry(self: &Arc, entry: Arc, id: String, attempt: u8) { + let manager = Arc::clone(self); + tokio::spawn(async move { + let delay = Duration::from_millis(200 * (1 << (attempt - 1))); + tokio::time::sleep(delay).await; + if entry.state.load(Ordering::Acquire) != PENDING { + return; + } + if entry.urgent.load(Ordering::Acquire) { + manager.queue.push_urgent(id); + } else { + manager.queue.push_background(id); + } + }); + } +} + +impl JobQueue { + fn push_urgent(&self, id: String) { + let should_notify = { + let mut state = self.state.lock().expect("thumbnail queue lock"); + if state.queued.contains(&id) { + if let Some(position) = state.background.iter().position(|queued| queued == &id) { + state.background.remove(position); + state.urgent.push_back(id); + true + } else { + false + } + } else { + state.queued.insert(id.clone()); + state.urgent.push_back(id); + true + } + }; + if !should_notify { + return; + } + self.notify.notify_one(); + } + + fn push_background(&self, id: String) { + let should_notify = { + let mut state = self.state.lock().expect("thumbnail queue lock"); + if state.queued.insert(id.clone()) { + state.background.push_back(id); + true + } else { + false + } + }; + if !should_notify { + return; + } + self.notify.notify_one(); + } + + async fn pop(&self) -> String { + loop { + let notified = self.notify.notified(); + if let Some(id) = { + let mut state = self.state.lock().expect("thumbnail queue lock"); + let id = state + .urgent + .pop_front() + .or_else(|| state.background.pop_front()); + if let Some(id) = &id { + state.queued.remove(id); + } + id + } { + return id; + } + notified.await; + } + } +} + +fn valid_cache_file(path: &Path) -> bool { + fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.len() > 20) + .unwrap_or(false) +} + +fn shard_path(root: &Path, id: &str) -> PathBuf { + let shard = id.get(..2).unwrap_or("00"); + root.join(shard).join(format!("{id}.webp")) +} + +fn generate_thumbnail(record: &ImageRecord, output: &Path) -> Result<()> { + generate_image_cache( + record, + output, + THUMBNAIL_EDGE, + THUMBNAIL_FILTER, + WEBP_QUALITY, + "thumbnail", + ) +} + +fn generate_preview(record: &ImageRecord, output: &Path) -> Result<()> { + generate_image_cache( + record, + output, + PREVIEW_EDGE, + PREVIEW_FILTER, + PREVIEW_QUALITY, + "viewer preview", + ) +} + +fn generate_image_cache( + record: &ImageRecord, + output: &Path, + max_edge: u32, + filter: FilterType, + quality: f32, + label: &str, +) -> Result<()> { + let reader = ImageReader::open(&record.path) + .with_context(|| format!("cannot open {}", record.path.display()))? + .with_guessed_format()?; + let mut decoder = reader.into_decoder()?; + let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); + let mut image = DynamicImage::from_decoder(decoder)?; + image.apply_orientation(orientation); + let (width, height) = image.dimensions(); + let image = if width.max(height) > max_edge { + image.resize(max_edge, max_edge, filter) + } else { + image + }; + write_webp(&image, output, quality) + .with_context(|| format!("cannot store {label}: {}", output.display())) +} + +fn write_webp(image: &DynamicImage, output: &Path, quality: f32) -> Result<()> { + let encoded = match image { + DynamicImage::ImageRgb8(rgb) => { + webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height()).encode(quality) + } + DynamicImage::ImageRgba8(rgba) => { + webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(quality) + } + _ => { + let rgba = image.to_rgba8(); + webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height()).encode(quality) + } + }; + let parent = output + .parent() + .with_context(|| format!("invalid image cache path: {}", output.display()))?; + fs::create_dir_all(parent)?; + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!( + ".{}.{}.{}.tmp", + output + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("image"), + std::process::id(), + sequence + )); + let write_result = (|| -> Result<()> { + let mut file = File::create(&temporary)?; + file.write_all(encoded.as_ref())?; + file.flush()?; + drop(file); + fs::rename(&temporary, output)?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(&temporary); + } + write_result +} + +fn cleanup_cache(root: &Path, valid_ids: &HashSet) { + for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() { + if !entry.file_type().is_file() + || entry.path().extension().and_then(|ext| ext.to_str()) != Some("webp") + { + continue; + } + let id = entry + .path() + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if !valid_ids.contains(id) + && let Err(error) = fs::remove_file(entry.path()) + { + warn!(path = %entry.path().display(), %error, "cannot remove stale thumbnail"); + } + } +} + +fn purge_temporary_files(root: &Path) { + for entry in WalkDir::new(root).min_depth(1).into_iter().flatten() { + if entry.file_type().is_file() + && entry.path().extension().and_then(|ext| ext.to_str()) == Some("tmp") + && let Err(error) = fs::remove_file(entry.path()) + { + warn!(path = %entry.path().display(), %error, "cannot remove temporary thumbnail"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gallery::scan_gallery; + + #[test] + fn generates_one_bounded_webp_thumbnail() { + let temp = tempfile::tempdir().unwrap(); + let gallery = temp.path().join("gallery"); + fs::create_dir(&gallery).unwrap(); + image::RgbImage::new(1200, 600) + .save(gallery.join("wide.png")) + .unwrap(); + let index = scan_gallery(&gallery, None).unwrap(); + let output = temp.path().join("thumbnail.webp"); + + generate_thumbnail(&index.images[0], &output).unwrap(); + let generated = image::open(output).unwrap(); + assert_eq!(generated.dimensions(), (720, 360)); + } + + #[test] + fn generates_bounded_viewer_preview_without_upscaling() { + let temp = tempfile::tempdir().unwrap(); + let gallery = temp.path().join("gallery"); + fs::create_dir(&gallery).unwrap(); + image::RgbImage::new(5000, 2500) + .save(gallery.join("wide.png")) + .unwrap(); + let index = scan_gallery(&gallery, None).unwrap(); + let output = temp.path().join("preview.webp"); + generate_preview(&index.images[0], &output).unwrap(); + assert_eq!(image::open(output).unwrap().dimensions(), (2560, 1280)); + + image::RgbImage::new(400, 200) + .save(gallery.join("small.png")) + .unwrap(); + let index = scan_gallery(&gallery, None).unwrap(); + let small = index + .images + .iter() + .find(|image| image.name == "small.png") + .unwrap(); + let output = temp.path().join("small.webp"); + generate_preview(small, &output).unwrap(); + assert_eq!(image::open(output).unwrap().dimensions(), (400, 200)); + } + + #[test] + fn queue_promotes_without_duplicate_jobs() { + let queue = JobQueue::default(); + queue.push_background("image".to_owned()); + queue.push_background("image".to_owned()); + queue.push_urgent("image".to_owned()); + + let state = queue.state.lock().unwrap(); + assert_eq!(state.urgent.as_slices().0, ["image"]); + assert!(state.background.is_empty()); + assert_eq!(state.queued.len(), 1); + } +} diff --git a/src/web.rs b/src/web.rs new file mode 100644 index 0000000..e91fd8e --- /dev/null +++ b/src/web.rs @@ -0,0 +1,351 @@ +use std::{cmp::Reverse, sync::Arc}; + +use axum::{ + Json, Router, + body::Body, + extract::{Path, Query, Request, State}, + http::{HeaderValue, Method, StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tower::ServiceExt; +use tower_http::{compression::CompressionLayer, services::ServeFile, trace::TraceLayer}; +use tracing::error; + +use crate::{ + gallery::{Album, GalleryIndex, ImageRecord}, + thumbs::{ThumbnailManager, ThumbnailStatus, ViewerPreviewManager}, +}; + +const INDEX_HTML: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/frontend/dist/index.html" +)); +const APP_JS: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/frontend/dist/assets/app.js" +)); +const APP_CSS: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/frontend/dist/assets/app.css" +)); + +#[derive(Clone)] +pub struct AppState { + pub index: Arc>, + pub thumbnails: Arc, + pub previews: Arc, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImagesQuery { + album: Option, + search: Option, + sort: Option, + offset: Option, + limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GallerySummary { + total: usize, + albums: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ImagesResponse { + items: Vec, + total: usize, + offset: usize, + limit: usize, + next_offset: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ImageView { + id: String, + name: String, + album: String, + relative_path: String, + width: u32, + height: u32, + size: u64, + modified_ms: u64, + thumbnail_url: String, + preview_url: String, + original_url: String, +} + +impl From<&ImageRecord> for ImageView { + fn from(record: &ImageRecord) -> Self { + Self { + id: record.id.clone(), + name: record.name.clone(), + album: record.album.clone(), + relative_path: record.relative_path.clone(), + width: record.width, + height: record.height, + size: record.size, + modified_ms: record.modified_ms, + thumbnail_url: format!("/api/images/{}/thumbnail", record.id), + preview_url: format!("/api/images/{}/preview", record.id), + original_url: format!("/api/images/{}/original", record.id), + } + } +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/api/health", get(health)) + .route("/api/gallery", get(gallery_summary)) + .route("/api/images", get(images)) + .route("/api/status", get(thumbnail_status)) + .route("/api/images/{id}/thumbnail", get(thumbnail)) + .route("/api/images/{id}/preview", get(preview)) + .route("/api/images/{id}/original", get(original)) + .route("/assets/app.js", get(app_js)) + .route("/assets/app.css", get(app_css)) + .fallback(frontend) + .layer(CompressionLayer::new()) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +async fn health() -> &'static str { + "ok" +} + +async fn gallery_summary(State(state): State) -> Json { + let index = state.index.read().await; + Json(GallerySummary { + total: index.images.len(), + albums: index.albums.clone(), + }) +} + +async fn images( + State(state): State, + Query(query): Query, +) -> Json { + let index = state.index.read().await; + let search = query + .search + .as_deref() + .map(str::trim) + .filter(|search| !search.is_empty()) + .map(str::to_lowercase); + let album = query.album.as_deref().filter(|album| !album.is_empty()); + + let mut matches: Vec<&Arc> = index + .images + .iter() + .filter(|record| album.is_none_or(|album| record.album == album)) + .filter(|record| { + search + .as_ref() + .is_none_or(|search| record.relative_path.to_lowercase().contains(search)) + }) + .collect(); + + match query.sort.as_deref() { + Some("name-desc") => matches.reverse(), + Some("newest") => matches.sort_by_key(|record| Reverse(record.modified_ms)), + _ => {} + } + + let total = matches.len(); + let offset = query.offset.unwrap_or(0).min(total); + let limit = query.limit.unwrap_or(60).clamp(1, 200); + let end = offset.saturating_add(limit).min(total); + let items = matches[offset..end] + .iter() + .map(|record| ImageView::from(record.as_ref())) + .collect(); + + Json(ImagesResponse { + items, + total, + offset, + limit, + next_offset: (end < total).then_some(end), + }) +} + +async fn thumbnail_status(State(state): State) -> Json { + Json(state.thumbnails.status()) +} + +async fn thumbnail( + State(state): State, + Path(id): Path, + request: Request, +) -> Result { + if state.index.read().await.get(&id).is_none() { + return Err(StatusCode::NOT_FOUND); + } + let path = state.thumbnails.ensure_ready(&id).await.map_err(|error| { + error!(%id, %error, "cannot serve thumbnail"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + serve_file( + path, + request, + &format!("\"thumb-{id}\""), + "public, max-age=31536000, immutable", + ) + .await +} + +async fn preview( + State(state): State, + Path(id): Path, + request: Request, +) -> Result { + if state.index.read().await.get(&id).is_none() { + return Err(StatusCode::NOT_FOUND); + } + let path = state.previews.ensure_ready(&id).await.map_err(|error| { + error!(%id, %error, "cannot serve viewer preview"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + serve_file( + path, + request, + &format!("\"preview-{id}\""), + "public, max-age=31536000, immutable", + ) + .await +} + +async fn original( + State(state): State, + Path(id): Path, + request: Request, +) -> Result { + let record = state + .index + .read() + .await + .get(&id) + .ok_or(StatusCode::NOT_FOUND)?; + serve_file( + record.path.clone(), + request, + &format!("\"image-{id}\""), + "private, max-age=3600", + ) + .await +} + +async fn serve_file( + path: std::path::PathBuf, + request: Request, + etag: &str, + cache_control: &'static str, +) -> Result { + if request + .headers() + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + == Some(etag) + { + let mut response = StatusCode::NOT_MODIFIED.into_response(); + add_cache_headers(&mut response, etag, cache_control)?; + return Ok(response); + } + + let response = ServeFile::new(path) + .oneshot(request) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let (parts, body) = response.into_parts(); + let mut response = Response::from_parts(parts, Body::new(body)); + add_cache_headers(&mut response, etag, cache_control)?; + Ok(response) +} + +fn add_cache_headers( + response: &mut Response, + etag: &str, + cache_control: &'static str, +) -> Result<(), StatusCode> { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(etag).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + Ok(()) +} + +async fn app_js() -> Response { + embedded(APP_JS, "text/javascript; charset=utf-8", true) +} + +async fn app_css() -> Response { + embedded(APP_CSS, "text/css; charset=utf-8", true) +} + +async fn frontend(request: Request) -> Response { + if request.method() != Method::GET && request.method() != Method::HEAD { + return StatusCode::METHOD_NOT_ALLOWED.into_response(); + } + embedded(INDEX_HTML, "text/html; charset=utf-8", false) +} + +fn embedded(content: &'static [u8], content_type: &'static str, immutable: bool) -> Response { + let mut response = Response::new(Body::from(content)); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(if immutable { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }), + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_view_uses_versioned_id_urls() { + let record = ImageRecord { + id: "abc".into(), + path: "photo.jpg".into(), + relative_path: "album/photo.jpg".into(), + name: "photo.jpg".into(), + album: "album".into(), + width: 10, + height: 20, + size: 30, + modified_ms: 40, + modified_ns: 40, + }; + let view = ImageView::from(&record); + assert_eq!(view.thumbnail_url, "/api/images/abc/thumbnail"); + assert_eq!(view.preview_url, "/api/images/abc/preview"); + assert_eq!(view.original_url, "/api/images/abc/original"); + } +}