From affe9bcd14c9f4cd63519e1ea5eae1fe781ad0db Mon Sep 17 00:00:00 2001 From: stefiosif Date: Sun, 28 Jun 2026 12:18:26 +0300 Subject: [PATCH] feat: generate and display thumbnails asynchronously --- .gitignore | 1 + ...1d7a0d0c4d04857130da327b07821f20cf07c.json | 8 +- ...1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json | 8 +- ...3897a12e58a29b0b2e5c814ff8cb392e32fa0.json | 8 +- ...3186407f6a72cc1596f652fcdcd7c7f49562d.json | 8 +- backend/Cargo.lock | 581 ++++++++++++++++++ backend/Cargo.toml | 1 + ...27202138_add_thumbnail_to_file_records.sql | 1 + backend/src/error.rs | 11 + backend/src/main.rs | 16 +- backend/src/model.rs | 67 ++ backend/src/tasks.rs | 81 +++ backend/src/web/routes_file.rs | 110 +++- frontend/src/lib/components/TileCard.svelte | 4 +- frontend/src/lib/types.ts | 1 + frontend/src/routes/+page.svelte | 13 + 16 files changed, 886 insertions(+), 33 deletions(-) create mode 100644 backend/migrations/20260627202138_add_thumbnail_to_file_records.sql create mode 100644 backend/src/tasks.rs diff --git a/.gitignore b/.gitignore index 4045ec0..b88ba94 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ node_modules/ .env.*.local docs/ + diff --git a/backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json b/backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json index eadd7a3..45e1529 100644 --- a/backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json +++ b/backend/.sqlx/query-31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c.json @@ -37,6 +37,11 @@ "ordinal": 6, "name": "user_id", "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "thumbnail_storage_path", + "type_info": "Text" } ], "parameters": { @@ -51,7 +56,8 @@ false, false, false, - false + false, + true ] }, "hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c" diff --git a/backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json b/backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json index 1f7e933..c9e5b23 100644 --- a/backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json +++ b/backend/.sqlx/query-638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e.json @@ -37,6 +37,11 @@ "ordinal": 6, "name": "user_id", "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "thumbnail_storage_path", + "type_info": "Text" } ], "parameters": { @@ -55,7 +60,8 @@ false, false, false, - false + false, + true ] }, "hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e" diff --git a/backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json b/backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json index cb7ec06..16fe1c4 100644 --- a/backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json +++ b/backend/.sqlx/query-d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0.json @@ -37,6 +37,11 @@ "ordinal": 6, "name": "user_id", "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "thumbnail_storage_path", + "type_info": "Text" } ], "parameters": { @@ -52,7 +57,8 @@ false, false, false, - false + false, + true ] }, "hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0" diff --git a/backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json b/backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json index 952646a..094824b 100644 --- a/backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json +++ b/backend/.sqlx/query-f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d.json @@ -37,6 +37,11 @@ "ordinal": 6, "name": "user_id", "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "thumbnail_storage_path", + "type_info": "Text" } ], "parameters": { @@ -52,7 +57,8 @@ false, false, false, - false + false, + true ] }, "hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d" diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d2d75d5..5fb5c86 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17,6 +17,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -47,6 +65,17 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "argon2" version = "0.5.3" @@ -59,6 +88,21 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "atoi" version = "2.0.0" @@ -80,6 +124,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -206,6 +293,7 @@ dependencies = [ "chrono", "dotenvy", "futures-util", + "image", "lazy-regex", "mime_guess", "opendal", @@ -247,6 +335,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.11.0" @@ -256,6 +350,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "blake2" version = "0.10.6" @@ -274,18 +377,36 @@ dependencies = [ "generic-array", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[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.11.1" @@ -356,6 +477,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -450,6 +577,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -465,6 +611,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -619,6 +771,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -697,12 +869,42 @@ dependencies = [ "syn", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[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.9" @@ -715,6 +917,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -923,6 +1126,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -958,6 +1171,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1277,6 +1501,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "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 = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "2.13.1" @@ -1289,6 +1553,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "inventory" version = "0.3.24" @@ -1304,6 +1579,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1462,12 +1746,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -1523,6 +1823,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1544,6 +1853,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1606,6 +1925,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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 = "multer" version = "3.1.0" @@ -1623,6 +1952,30 @@ dependencies = [ "version_check", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nonempty" version = "0.7.0" @@ -1635,6 +1988,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1699,6 +2058,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1911,6 +2281,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1985,6 +2367,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[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 = "portable-atomic" version = "1.13.1" @@ -2053,6 +2448,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quanta" version = "0.12.6" @@ -2068,6 +2497,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -2231,6 +2666,56 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-cpuid" version = "11.6.0" @@ -2240,6 +2725,26 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2356,6 +2861,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -2788,6 +3299,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3148,6 +3668,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -3575,6 +4109,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3791,6 +4336,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -4143,6 +4694,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yansi" version = "1.0.1" @@ -4289,3 +4846,27 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[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/backend/Cargo.toml b/backend/Cargo.toml index 3ef5e20..37bfdc0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -25,6 +25,7 @@ mime_guess = "2.0.5" tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] } utoipa = { version = "5.5.0", features = ["axum_extras", "chrono"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } +image = "0.25.10" [dev-dependencies] axum-test = "20.0.0" diff --git a/backend/migrations/20260627202138_add_thumbnail_to_file_records.sql b/backend/migrations/20260627202138_add_thumbnail_to_file_records.sql new file mode 100644 index 0000000..d87dcd5 --- /dev/null +++ b/backend/migrations/20260627202138_add_thumbnail_to_file_records.sql @@ -0,0 +1 @@ +ALTER TABLE file_records ADD COLUMN thumbnail_storage_path TEXT; diff --git a/backend/src/error.rs b/backend/src/error.rs index 650f628..e706ef1 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -19,6 +19,7 @@ pub enum LoftError { NoFileProvided, MultipartError(String), InvalidRange, + ThumbnailGenerationError(String), } impl fmt::Display for LoftError { @@ -51,6 +52,12 @@ impl From for LoftError { } } +impl From for LoftError { + fn from(value: image::ImageError) -> Self { + LoftError::ThumbnailGenerationError(value.to_string()) + } +} + impl std::error::Error for LoftError {} impl IntoResponse for LoftError { @@ -80,6 +87,10 @@ impl IntoResponse for LoftError { error!("opendal storage error: {e}"); StatusCode::INTERNAL_SERVER_ERROR.into_response() } + Self::ThumbnailGenerationError(e) => { + error!("thumbnail generation error: {e}"); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } Self::NoFileProvided => StatusCode::BAD_REQUEST.into_response(), Self::MultipartError(e) => { info!("bad request: {e}"); diff --git a/backend/src/main.rs b/backend/src/main.rs index e2c64bc..f90ab9d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,7 @@ mod ctx; mod error; mod model; +mod tasks; mod web; use std::{net::SocketAddr, time::Duration}; @@ -13,6 +14,7 @@ use axum::{ middleware, }; use sqlx::PgPool; +use tokio::sync::mpsc::{self}; use tower_cookies::CookieManagerLayer; use tower_governor::{ GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, @@ -31,12 +33,15 @@ use crate::{ model::{FileRepository, UserRepository}, web::{ mw_auth::{mw_ctx_resolver, mw_require_auth}, - routes_file::{FileApi, routes_file}, + routes_file::{FileApi, FileState, routes_file}, routes_health::{HealthApi, routes_health}, routes_login::{AuthApi, routes_auth}, }, }; +const BUFFER_SIZE: usize = 100; +const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5; + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::registry() @@ -51,11 +56,16 @@ async fn main() -> Result<()> { dotenvy::dotenv().ok(); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5; let pool = PgPool::connect(&database_url).await?; sqlx::migrate!().run(&pool).await?; + let file_repository = FileRepository::new(pool.clone())?; - let routes_file = routes_file(file_repository.clone()) + + let (tx, rx) = mpsc::channel(BUFFER_SIZE); + tokio::spawn(tasks::run_thumbnail_worker(rx, file_repository.clone())); + + let file_service = FileState::new(file_repository.clone(), tx); + let routes_file = routes_file(file_service) .route_layer(middleware::from_fn(mw_require_auth)) .layer(DefaultBodyLimit::max(BODY_LIMIT)); diff --git a/backend/src/model.rs b/backend/src/model.rs index 375108e..57b0163 100644 --- a/backend/src/model.rs +++ b/backend/src/model.rs @@ -17,6 +17,7 @@ pub struct FileRecord { #[serde(skip_serializing)] pub storage_key: String, pub uploaded_at: chrono::DateTime, + pub thumbnail_storage_path: Option, } #[derive(Clone)] @@ -90,6 +91,18 @@ impl FileRepository { Ok(stream) } + pub async fn thumbnail( + &self, + file_id: i64, + user_id: i64, + ) -> Result> + use<>> { + let record = self.get_file(file_id, user_id).await?; + let path = format!("thumbnail/{}", &record.storage_key); + let reader = self.op.reader(&path).await?; + let stream = reader.into_bytes_stream(0..).await?; + Ok(stream) + } + pub async fn stream_part( &self, file_id: i64, @@ -155,6 +168,29 @@ impl FileRepository { Ok(records) } + + pub async fn update_thumbnail_storage_path( + &self, + path: &str, + file_record_id: i64, + user_id: i64, + ) -> Result<()> { + sqlx::query!( + r#" + UPDATE file_records + SET thumbnail_storage_path = $1 + WHERE id = $2 + AND user_id = $3 + "#, + path, + file_record_id, + user_id + ) + .execute(&self.pool) + .await?; + + Ok(()) + } } #[derive(Clone, Debug, FromRow)] @@ -383,6 +419,37 @@ mod tests { )); } + #[tokio::test] + #[serial_test::serial] + async fn test_update_thumbnail_storage_path() { + let user_repository = user_repository().await.unwrap(); + let user = user_repository + .create_user("username", "password_hash") + .await + .unwrap(); + let file_repository = file_repository().await.unwrap(); + truncate_file_records(&file_repository.pool).await; + + let record = file_repository + .create_file_record(user.id, "a.png", 2, "a.png-uuid") + .await + .unwrap(); + + assert_eq!(record.thumbnail_storage_path, None); + + let path = "thumbnail/a.png-uuid"; + file_repository + .update_thumbnail_storage_path(path, record.id, user.id) + .await + .unwrap(); + + let updated = file_repository.get_file(record.id, user.id).await.unwrap(); + assert_eq!(updated.thumbnail_storage_path, Some(path.to_string())); + + truncate_file_records(&file_repository.pool).await; + truncate_users(&user_repository.pool).await; + } + #[tokio::test] #[serial_test::serial] async fn test_stream_part() { diff --git a/backend/src/tasks.rs b/backend/src/tasks.rs new file mode 100644 index 0000000..9d2fae6 --- /dev/null +++ b/backend/src/tasks.rs @@ -0,0 +1,81 @@ +use image::codecs::jpeg::JpegEncoder; +use tokio::sync::mpsc::Receiver; +use tracing::{info, warn}; + +use crate::{ + error::Result, + model::{FileRecord, FileRepository}, +}; + +// change thumbnail task to have the file id in the param +// with the file id we can call the file repository and get the file record +// which will grand us access to the opendal storage... and also the MIME type +// so we can have a match statement after a parser that will give us the type of +// process we should do in order to create the thumbnail.. +// for example an image will be processed with image crate but a video will be processed with tokio::command->ffmpeg +#[derive(Clone, Debug)] +pub struct ThumbnailTask { + record_id: i64, + user_id: i64, +} + +impl ThumbnailTask { + pub fn new(record_id: i64, user_id: i64) -> Self { + Self { record_id, user_id } + } +} + +pub async fn run_thumbnail_worker( + mut rx: Receiver, + file_repository: FileRepository, +) { + while let Some(task) = rx.recv().await { + let record_id = task.record_id; + + if let Err(e) = process_task(task, &file_repository).await { + warn!("thumbnail task {} failed: {}", record_id, e); + } + } +} + +async fn process_task(task: ThumbnailTask, file_repository: &FileRepository) -> Result<()> { + let record_id = task.record_id; + info!("task for file record {} received", record_id); + + let record = file_repository + .get_file(task.record_id, task.user_id) + .await?; + let file_type = &record.file_type; + + // TODO: create enum that maps file_type (which is actually mime_type) into file category (image, video, ...) + if file_type.contains("image") { + generate_for_image(&record, file_repository).await?; + } else { + warn!( + "thumbnail generation for file type {} not yet supported", + file_type + ); + } + + Ok(()) +} + +async fn generate_for_image(record: &FileRecord, file_repository: &FileRepository) -> Result<()> { + let storage_key = &record.storage_key; + let bytes = file_repository.op.read(storage_key).await?.to_vec(); + + let image = image::load_from_memory(&bytes)?; + let thumbnail = image.thumbnail(256, 256); + let rgb = thumbnail.to_rgb8(); + let mut buf = Vec::new(); + let mut enc = JpegEncoder::new_with_quality(&mut buf, 50); + enc.encode_image(&rgb)?; + + let path = format!("thumbnail/{}", storage_key); + file_repository.op.write(&path, buf).await?; + file_repository + .update_thumbnail_storage_path(&path, record.id, record.user_id) + .await?; + + Ok(()) +} diff --git a/backend/src/web/routes_file.rs b/backend/src/web/routes_file.rs index 994af5a..84e94c0 100644 --- a/backend/src/web/routes_file.rs +++ b/backend/src/web/routes_file.rs @@ -7,12 +7,15 @@ use axum::{ routing::get, }; use sqlx::types::uuid; +use tokio::sync::mpsc::Sender; +use tracing::warn; use utoipa::OpenApi; use crate::{ ctx::Ctx, error::{LoftError, Result}, model::{FileRecord, FileRepository}, + tasks::ThumbnailTask, }; #[derive(OpenApi)] @@ -23,19 +26,36 @@ use crate::{ get_file, delete_file, download_file, + thumbnail, stream_part ), components(schemas(FileRecord)) )] pub struct FileApi; -pub fn routes_file(file_repository: FileRepository) -> Router { +#[derive(Clone)] +pub struct FileState { + pub file_repository: FileRepository, + pub tx: Sender, +} + +impl FileState { + pub fn new(file_repository: FileRepository, tx: Sender) -> Self { + Self { + file_repository, + tx, + } + } +} + +pub fn routes_file(file_service: FileState) -> Router { Router::new() .route("/files", get(list_files).post(upload_file)) .route("/files/{id}", get(get_file).delete(delete_file)) .route("/files/{id}/download", get(download_file)) + .route("/files/{id}/thumbnail", get(thumbnail)) .route("/files/{id}/stream_part", get(stream_part)) - .with_state(file_repository) + .with_state(file_service) } /// Upload a file @@ -53,7 +73,7 @@ pub fn routes_file(file_repository: FileRepository) -> Router { ) )] async fn upload_file( - State(file_repository): State, + State(service): State, ctx: Ctx, mut multipart: Multipart, ) -> Result<(StatusCode, Json)> { @@ -63,18 +83,27 @@ async fn upload_file( if field.name() == Some("file") { let name = field.file_name().map(str::to_string).unwrap_or_default(); let key = format!("{name}-{}", uuid::Uuid::new_v4()); - let size = file_repository.upload_file(field, &key).await?; + let size = service.file_repository.upload_file(field, &key).await?; uploaded = Some((name, key, size)); } } let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?; - let file_record = file_repository + let record = service + .file_repository .create_file_record(ctx.user_id(), &name, size, &key) .await?; - Ok((StatusCode::CREATED, Json(file_record))) + if let Err(e) = service + .tx + .send(ThumbnailTask::new(record.id, ctx.user_id())) + .await + { + warn!("failed to send thumbnail task for file {}: {e}", record.id) + } + + Ok((StatusCode::CREATED, Json(record))) } /// Get a file's metadata @@ -92,11 +121,12 @@ async fn upload_file( ) )] async fn get_file( - State(file_repository): State, + State(service): State, ctx: Ctx, Path(file_id): Path, ) -> Result> { - let record = file_repository + let record = service + .file_repository .get_file(file_id as i64, ctx.user_id()) .await?; Ok(Json(record)) @@ -117,16 +147,43 @@ async fn get_file( ) )] async fn download_file( - State(file_repository): State, + State(service): State, ctx: Ctx, Path(file_id): Path, ) -> Result { - let stream = file_repository + let stream = service + .file_repository .download_file(file_id as i64, ctx.user_id()) .await?; Ok(Body::from_stream(stream)) } +/// Download a file thumbnail +#[utoipa::path( + get, + path = "/api/files/{id}/thumbnail", + tag = "files", + security(("cookie_auth" = [])), + params(("id" = u64, Path, description = "File id")), + responses( + (status = 200, description = "File bytes", content_type = "application/octet-stream"), + (status = 404, description = "File not found"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ) +)] +async fn thumbnail( + State(service): State, + ctx: Ctx, + Path(file_id): Path, +) -> Result { + let stream = service + .file_repository + .thumbnail(file_id as i64, ctx.user_id()) + .await?; + Ok(Body::from_stream(stream)) +} + /// Stream a byte range of a file (HTTP 206) #[utoipa::path( get, @@ -146,24 +203,26 @@ async fn download_file( ) )] async fn stream_part( - State(file_repository): State, + State(service): State, ctx: Ctx, headers: HeaderMap, Path(file_id): Path, ) -> Result { - let file_record = file_repository + let record = service + .file_repository .get_file(file_id as i64, ctx.user_id()) .await?; - let file_size = file_record.size as u64; + let file_size = record.size as u64; let (start, end): (u64, u64) = parse_range(&headers, file_size)?; - let stream = file_repository + let stream = service + .file_repository .stream_part(file_id as i64, ctx.user_id(), start, end + 1) .await?; let respones = Response::builder() .status(StatusCode::PARTIAL_CONTENT) - .header(header::CONTENT_TYPE, file_record.file_type) + .header(header::CONTENT_TYPE, record.file_type) .header(header::CONTENT_LENGTH, end - start + 1) .header( header::CONTENT_RANGE, @@ -207,11 +266,12 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> { ) )] async fn delete_file( - State(file_repository): State, + State(service): State, ctx: Ctx, Path(file_id): Path, ) -> Result> { - let file = file_repository + let file = service + .file_repository .delete_file(file_id as i64, ctx.user_id()) .await?; Ok(Json(file)) @@ -229,11 +289,8 @@ async fn delete_file( (status = 500, description = "Internal server error") ) )] -async fn list_files( - State(file_repository): State, - ctx: Ctx, -) -> Result>> { - let files = file_repository.list_files(ctx.user_id()).await?; +async fn list_files(State(service): State, ctx: Ctx) -> Result>> { + let files = service.file_repository.list_files(ctx.user_id()).await?; Ok(Json(files)) } @@ -251,13 +308,14 @@ mod tests { use rand::RngExt; use serde_json::json; use sqlx::PgPool; + use tokio::sync::mpsc; use tower_cookies::CookieManagerLayer; use crate::{ model::{FileRepository, UserRepository}, web::{ mw_auth::{mw_ctx_resolver, mw_require_auth}, - routes_file::routes_file, + routes_file::{FileState, routes_file}, }, }; @@ -282,8 +340,12 @@ mod tests { async fn test_server() -> TestServer { let user_repository = user_repository().await; let file_repository = file_repository().await; + + let (tx, _) = mpsc::channel(100); + let file_service = FileState::new(file_repository.clone(), tx); + let routes_file = - routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth)); + routes_file(file_service).route_layer(middleware::from_fn(mw_require_auth)); let app = Router::new() .nest("/api", routes_file) .layer(middleware::from_fn_with_state( diff --git a/frontend/src/lib/components/TileCard.svelte b/frontend/src/lib/components/TileCard.svelte index 08ac5ca..6db6c83 100644 --- a/frontend/src/lib/components/TileCard.svelte +++ b/frontend/src/lib/components/TileCard.svelte @@ -40,9 +40,9 @@ class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50 {selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}" > - {#if isImage} + {#if isImage && file.thumbnailStoragePath} {file.name} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 4e15d17..af50854 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -4,4 +4,5 @@ export type FileRecord = { size: number; fileType: string; uploadedAt: string; + thumbnailStoragePath: string | null; }; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 822ddcf..3c33b32 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -82,6 +82,19 @@ window.location.href = '/auth'; } + $effect(() => { + const pending = fileRecords.some( + (f) => f.fileType.includes('image') && !f.thumbnailStoragePath + ); + + if (!pending) { + return; + } + + const id = setInterval(refresh, 1000); + return () => clearInterval(id); + }); + onMount(() => { const saved = localStorage.getItem('viewMode'); if (saved === 'rows' || saved === 'tiles') viewMode = saved;