diff --git a/README.md b/README.md new file mode 100644 index 0000000..30fa934 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Paperless-NEO + +## Backend Integration Tests + +Integration tests require a running Postgres instance. The repository includes a lightweight compose file for local runs: + +```bash +docker compose -f docker-compose.test.yml up -d +export TEST_DATABASE_URL=postgres://paperless:paperless_test@localhost:5433/paperless_test +cargo test +``` + +Stop the database when you are done: + +```bash +docker compose -f docker-compose.test.yml down +``` + +The compose service uses tmpfs storage, giving each test run a clean database. diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 63e83b7..780f3a4 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -692,12 +692,24 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[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.10.1" @@ -775,6 +787,26 @@ dependencies = [ "cc", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1103,6 +1135,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.12.1" @@ -1119,6 +1160,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" +[[package]] +name = "flate2" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1652,6 +1703,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -1796,6 +1862,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.10.6" @@ -1839,6 +1911,16 @@ 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 = "minimal-lexical" version = "0.2.1" @@ -1852,6 +1934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1865,6 +1948,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moxcms" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc7d85f3d741164e8972ad355e26ac6e51b20fcae5f911c7da8f2d8bbbb3f33" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multer" version = "3.1.0" @@ -1993,8 +2086,11 @@ dependencies = [ "hex", "http-body-util", "hyper 1.7.0", + "image", "jsonwebtoken", + "mime_guess", "once_cell", + "pdfium-render", "rand 0.8.5", "serde", "serde_json", @@ -2042,6 +2138,32 @@ dependencies = [ "subtle", ] +[[package]] +name = "pdfium-render" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8fa6e8ceaad45c9fed63db09045c45f2cccd4a8b92a826bd3f8e901a98b14c6" +dependencies = [ + "bitflags", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "image", + "itertools", + "js-sys", + "libloading", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "pem" version = "3.0.5" @@ -2090,6 +2212,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + [[package]] name = "pkcs8" version = "0.9.0" @@ -2106,6 +2234,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -2160,6 +2301,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + [[package]] name = "quote" version = "1.0.41" @@ -2658,6 +2808,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simple_asn1" version = "0.6.3" @@ -3084,6 +3240,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -3114,6 +3276,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3144,6 +3315,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + [[package]] name = "version_check" version = "0.9.5" @@ -3216,6 +3396,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.104" @@ -3248,6 +3441,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3590,3 +3793,18 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 320e0b8..e01da70 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -34,6 +34,9 @@ sha2 = "0.10" hex = "0.4" bytes = "1.5" async-trait = "0.1" +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +pdfium-render = "0.8" +mime_guess = "2.0" # Error handling thiserror = "1.0" diff --git a/backend/libpdfium.dylib b/backend/libpdfium.dylib new file mode 100644 index 0000000..5f5e998 Binary files /dev/null and b/backend/libpdfium.dylib differ diff --git a/backend/migrations/202502130001_create_jobs/down.sql b/backend/migrations/202502130001_create_jobs/down.sql new file mode 100644 index 0000000..5f795aa --- /dev/null +++ b/backend/migrations/202502130001_create_jobs/down.sql @@ -0,0 +1,5 @@ +DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs; +DROP FUNCTION IF EXISTS touch_jobs_updated_at; +DROP INDEX IF EXISTS idx_jobs_job_type; +DROP INDEX IF EXISTS idx_jobs_status_run_after; +DROP TABLE IF EXISTS jobs; diff --git a/backend/migrations/202502130001_create_jobs/up.sql b/backend/migrations/202502130001_create_jobs/up.sql new file mode 100644 index 0000000..4d7f4a1 --- /dev/null +++ b/backend/migrations/202502130001_create_jobs/up.sql @@ -0,0 +1,28 @@ +CREATE TABLE jobs ( + id UUID PRIMARY KEY, + job_type TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + run_after TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')) +); + +CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after); +CREATE INDEX idx_jobs_job_type ON jobs (job_type); + +CREATE OR REPLACE FUNCTION touch_jobs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_jobs_updated_at +BEFORE UPDATE ON jobs +FOR EACH ROW +EXECUTE FUNCTION touch_jobs_updated_at(); diff --git a/backend/migrations/202502130002_create_document_assets/down.sql b/backend/migrations/202502130002_create_document_assets/down.sql new file mode 100644 index 0000000..9976f4c --- /dev/null +++ b/backend/migrations/202502130002_create_document_assets/down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_document_assets_type; +DROP INDEX IF EXISTS idx_document_assets_version; +DROP TABLE IF EXISTS document_assets; diff --git a/backend/migrations/202502130002_create_document_assets/up.sql b/backend/migrations/202502130002_create_document_assets/up.sql new file mode 100644 index 0000000..d9a1ff1 --- /dev/null +++ b/backend/migrations/202502130002_create_document_assets/up.sql @@ -0,0 +1,15 @@ +CREATE TABLE document_assets ( + id UUID PRIMARY KEY, + document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE, + asset_type TEXT NOT NULL, + s3_key TEXT NOT NULL, + mime_type TEXT NOT NULL, + width INTEGER, + height INTEGER, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type) +); + +CREATE INDEX idx_document_assets_version ON document_assets(document_version_id); +CREATE INDEX idx_document_assets_type ON document_assets(asset_type); diff --git a/backend/migrations/202503040001_enforce_unique_folder_names/down.sql b/backend/migrations/202503040001_enforce_unique_folder_names/down.sql new file mode 100644 index 0000000..7484794 --- /dev/null +++ b/backend/migrations/202503040001_enforce_unique_folder_names/down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS folders_parent_name_unique_idx; + +ALTER TABLE folders + ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name); diff --git a/backend/migrations/202503040001_enforce_unique_folder_names/up.sql b/backend/migrations/202503040001_enforce_unique_folder_names/up.sql new file mode 100644 index 0000000..2164fb8 --- /dev/null +++ b/backend/migrations/202503040001_enforce_unique_folder_names/up.sql @@ -0,0 +1,5 @@ +ALTER TABLE folders + DROP CONSTRAINT IF EXISTS folders_parent_name_unique; + +CREATE UNIQUE INDEX folders_parent_name_unique_idx + ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name); diff --git a/backend/src/bin/worker.rs b/backend/src/bin/worker.rs new file mode 100644 index 0000000..8ae9f1a --- /dev/null +++ b/backend/src/bin/worker.rs @@ -0,0 +1,42 @@ +use std::{sync::Arc, time::Duration}; + +use tokio::signal; +use tracing_subscriber::EnvFilter; + +use paperless_backend::{ + auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client, + state::AppState, storage::S3Storage, Worker, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv::dotenv().ok(); + init_tracing(); + + let config = AppConfig::from_env()?; + let pool = db::init_pool(&config.database_url)?; + let s3_client = build_client(&config).await?; + let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone())); + let jwt = JwtService::from_config(&config)?; + + let state = Arc::new(AppState::new(pool, config, storage, jwt)); + let worker = Worker::new(state, default_handlers(), Duration::from_secs(2)); + + tokio::select! { + _ = worker.run() => {} + _ = signal::ctrl_c() => { + tracing::info!("worker received shutdown signal"); + } + } + + Ok(()) +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .compact() + .init(); +} diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs new file mode 100644 index 0000000..d7385fe --- /dev/null +++ b/backend/src/jobs.rs @@ -0,0 +1,127 @@ +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::pg::PgConnection; +use diesel::prelude::*; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +use crate::models::{Job, NewJob}; +use crate::schema::jobs; + +pub const STATUS_QUEUED: &str = "queued"; +pub const STATUS_PROCESSING: &str = "processing"; +pub const STATUS_SUCCEEDED: &str = "succeeded"; +pub const STATUS_FAILED: &str = "failed"; + +pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document"; +pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; + +#[derive(Debug, Error)] +pub enum JobQueueError { + #[error("database error: {0}")] + Database(#[from] diesel::result::Error), +} + +pub type JobQueueResult = Result; + +pub fn enqueue_job( + conn: &mut PgConnection, + job_type: &str, + payload: Value, + run_after: Option, +) -> JobQueueResult { + let new_job = NewJob { + id: Uuid::new_v4(), + job_type: job_type.to_string(), + payload, + status: STATUS_QUEUED.to_string(), + run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()), + }; + + diesel::insert_into(jobs::table) + .values(&new_job) + .execute(conn)?; + + let job = jobs::table.find(new_job.id).first(conn)?; + Ok(job) +} + +pub fn reserve_job(conn: &mut PgConnection, job_types: &[&str]) -> JobQueueResult> { + let now = Utc::now().naive_utc(); + + conn.transaction(|conn| { + let job_opt = jobs::table + .filter(jobs::status.eq(STATUS_QUEUED)) + .filter(jobs::run_after.le(now)) + .filter(jobs::job_type.eq_any(job_types)) + .order(jobs::run_after.asc()) + .for_update() + .skip_locked() + .first::(conn) + .optional()?; + + if let Some(job) = job_opt { + diesel::update(jobs::table.find(job.id)) + .set(( + jobs::status.eq(STATUS_PROCESSING), + jobs::attempts.eq(job.attempts + 1), + jobs::updated_at.eq(now), + )) + .execute(conn)?; + + let refreshed = jobs::table.find(job.id).first(conn)?; + Ok::, diesel::result::Error>(Some(refreshed)) + } else { + Ok::, diesel::result::Error>(None) + } + }) + .map_err(JobQueueError::from) +} + +pub fn mark_job_succeeded(conn: &mut PgConnection, job_id: Uuid) -> JobQueueResult<()> { + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_SUCCEEDED), + jobs::last_error.eq::>(None), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} + +pub fn retry_job_after( + conn: &mut PgConnection, + job_id: Uuid, + delay: Duration, + error_message: &str, +) -> JobQueueResult<()> { + let next_run = Utc::now() + + ChronoDuration::from_std(delay).unwrap_or_else(|_| ChronoDuration::seconds(30)); + + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_QUEUED), + jobs::run_after.eq(next_run.naive_utc()), + jobs::last_error.eq(Some(error_message.to_string())), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} + +pub fn mark_job_failed( + conn: &mut PgConnection, + job_id: Uuid, + error_message: &str, +) -> JobQueueResult<()> { + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_FAILED), + jobs::last_error.eq(Some(error_message.to_string())), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 7ee0696..19a161f 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -2,9 +2,12 @@ pub mod auth; pub mod config; pub mod db; pub mod error; +pub mod jobs; pub mod models; pub mod routes; pub mod s3; pub mod schema; pub mod state; pub mod storage; +pub mod workers; +pub use workers::{default_handlers, Worker}; diff --git a/backend/src/models.rs b/backend/src/models.rs index b156504..bef2eb0 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -98,6 +98,58 @@ pub struct NewDocumentVersion { pub operations_summary: serde_json::Value, } +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = document_assets)] +#[diesel(belongs_to(DocumentVersion, foreign_key = document_version_id))] +pub struct DocumentAsset { + pub id: Uuid, + pub document_version_id: Uuid, + pub asset_type: String, + pub s3_key: String, + pub mime_type: String, + pub width: Option, + pub height: Option, + pub metadata: serde_json::Value, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_assets)] +pub struct NewDocumentAsset { + pub id: Uuid, + pub document_version_id: Uuid, + pub asset_type: String, + pub s3_key: String, + pub mime_type: String, + pub width: Option, + pub height: Option, + pub metadata: serde_json::Value, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = jobs)] +pub struct Job { + pub id: Uuid, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub attempts: i32, + pub run_after: NaiveDateTime, + pub last_error: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = jobs)] +pub struct NewJob { + pub id: Uuid, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub run_after: NaiveDateTime, +} + #[derive(Debug, Clone, Queryable, Identifiable)] #[diesel(table_name = tags)] pub struct Tag { diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 2241766..8a61338 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -1,5 +1,4 @@ -use std::collections::HashMap; -use std::time::Duration; +use std::{collections::HashMap, time::Duration}; use axum::extract::{Json, Multipart, Path, Query, State}; use axum::http::StatusCode; @@ -8,16 +7,18 @@ use chrono::{DateTime, NaiveDateTime, Utc}; use diesel::dsl::exists; use diesel::{prelude::*, PgConnection}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use sha2::{Digest, Sha256}; +use tracing::{error, info, warn}; use uuid::Uuid; use crate::auth::AuthenticatedUser; use crate::error::{AppError, AppResult}; +use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; use crate::models::{ - Document, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, + Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentTag, NewDocumentVersion, Tag, }; -use crate::schema::{document_tags, document_versions, documents, folders, tags}; +use crate::schema::{document_assets, document_tags, document_versions, documents, folders, tags}; use crate::state::AppState; const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; @@ -29,6 +30,12 @@ pub struct DocumentListQuery { pub include_deleted: bool, } +#[derive(Deserialize)] +pub struct AssetRequestQuery { + #[serde(default)] + pub force: bool, +} + #[derive(Serialize)] pub struct TagResponse { pub id: Uuid, @@ -59,9 +66,10 @@ pub struct DocumentResponse { pub deleted_at: Option, pub metadata: Value, pub tags: Vec, + pub thumbnail: Option, } -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub struct DocumentVersionResponse { pub id: Uuid, pub version_number: i32, @@ -72,10 +80,22 @@ pub struct DocumentVersionResponse { pub operations_summary: Value, } +#[derive(Serialize, Clone)] +pub struct DocumentAssetResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub width: Option, + pub height: Option, + pub url: String, + pub created_at: String, +} + #[derive(Serialize)] pub struct DocumentDetailResponse { pub document: DocumentResponse, pub current_version: DocumentVersionResponse, + pub assets: Vec, } #[derive(Serialize)] @@ -87,6 +107,24 @@ pub struct DocumentDownloadResponse { pub size_bytes: i64, } +#[derive(Serialize)] +pub struct BulkReanalyzeResponse { + pub queued: usize, +} + +struct UploadRequest { + bytes: Vec, + original_name: String, + content_type: Option, + folder_id: Option, + metadata: Value, +} + +struct UploadOutcome { + detail: DocumentDetailResponse, + created: bool, +} + #[derive(Deserialize)] pub struct MoveDocumentRequest { pub folder_id: Option, @@ -124,12 +162,16 @@ pub async fn list_documents( let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; + drop(conn); + + let thumbnails = load_primary_thumbnails(&state, &docs).await?; let response = docs .into_iter() .map(|doc| { let tags = tags_map.get(&doc.id).cloned(); - to_document_response(doc, tags) + let thumbnail = thumbnails.get(&doc.id).cloned(); + to_document_response(doc, tags, thumbnail) }) .collect(); @@ -153,10 +195,19 @@ pub async fn get_document( .first(&mut conn)?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; + let version_id = current_version.id; + drop(conn); + + let assets = load_asset_responses(&state, version_id).await?; + let thumbnail = assets + .iter() + .find(|asset| asset.asset_type == "thumbnail") + .cloned(); Ok(Json(DocumentDetailResponse { - document: to_document_response(doc, tags_map.get(&document_id).cloned()), + document: to_document_response(doc, tags_map.get(&document_id).cloned(), thumbnail), current_version: to_version_response(current_version), + assets, })) } @@ -164,18 +215,18 @@ pub async fn upload_document( State(state): State, _user: AuthenticatedUser, mut multipart: Multipart, -) -> AppResult> { +) -> AppResult<(StatusCode, Json)> { let mut file_bytes: Option> = None; let mut original_name: Option = None; let mut content_type: Option = None; let mut folder_id: Option = None; let mut metadata: Value = Value::Object(Default::default()); - while let Some(field) = multipart - .next_field() - .await - .map_err(|err| AppError::bad_request(format!("invalid multipart data: {err}")))? - { + while let Some(field) = multipart.next_field().await.map_err(|err| { + let msg = format!("invalid multipart data: {err}"); + warn!(error = %err, "invalid multipart data"); + AppError::bad_request(msg) + })? { let name = field.name().map(|n| n.to_string()); match name.as_deref() { Some("file") => { @@ -183,15 +234,18 @@ pub async fn upload_document( original_name = file_name.clone(); content_type = field.content_type().map(|mime| mime.to_string()); let data = field.bytes().await.map_err(|err| { - AppError::bad_request(format!("failed to read file bytes: {err}")) + let msg = format!("failed to read file bytes: {err}"); + warn!(error = %err, "failed to read file bytes"); + AppError::bad_request(msg) })?; file_bytes = Some(data.to_vec()); } Some("folder_id") => { - let value = field - .text() - .await - .map_err(|err| AppError::bad_request(format!("invalid folder id: {err}")))?; + let value = field.text().await.map_err(|err| { + let msg = format!("invalid folder id: {err}"); + warn!(error = %err, "invalid folder id"); + AppError::bad_request(msg) + })?; if !value.trim().is_empty() { let parsed = Uuid::parse_str(value.trim()) .map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?; @@ -199,127 +253,138 @@ pub async fn upload_document( } } Some("metadata") => { - let value = field - .text() - .await - .map_err(|err| AppError::bad_request(format!("invalid metadata: {err}")))?; + let value = field.text().await.map_err(|err| { + let msg = format!("invalid metadata: {err}"); + warn!(error = %err, "invalid metadata payload"); + AppError::bad_request(msg) + })?; metadata = serde_json::from_str(&value).map_err(|err| { - AppError::bad_request(format!("metadata must be valid JSON: {err}")) + let msg = format!("metadata must be valid JSON: {err}"); + warn!(error = %err, "metadata parse failure"); + AppError::bad_request(msg) })?; } _ => {} } } - let file_bytes = file_bytes.ok_or_else(|| AppError::bad_request("file field is required"))?; + let file_bytes = file_bytes.ok_or_else(|| { + warn!("upload rejected: missing file field"); + AppError::bad_request("file field is required") + })?; let original_name = original_name.unwrap_or_else(|| "upload.bin".to_string()); - if let Some(folder_id) = folder_id { - ensure_folder_exists(&state, folder_id)?; - } + let request = UploadRequest { + bytes: file_bytes, + original_name, + content_type, + folder_id, + metadata, + }; - let doc_id = Uuid::new_v4(); - let version_id = Uuid::new_v4(); - let version_number = 1; - let stored_filename = original_name.clone(); - - let checksum = Sha256::digest(&file_bytes); - let checksum_hex = hex::encode(checksum); - let size_bytes = file_bytes.len() as i64; - - let s3_key = format!("documents/{doc_id}/v{version_number}/{version_id}"); - - { - let mut conn = state.db()?; - - let existing = documents::table - .inner_join( - document_versions::table.on(document_versions::document_id - .eq(documents::id) - .and(document_versions::version_number.eq(documents::current_version))), - ) - .filter(document_versions::checksum.eq(&checksum_hex)) - .select((documents::all_columns, document_versions::all_columns)) - .first::<(Document, DocumentVersion)>(&mut conn) - .optional()?; - - if let Some((mut document, version)) = existing { - if document.deleted_at.is_some() { - let now = Utc::now().naive_utc(); - diesel::update(documents::table.find(document.id)) - .set(( - documents::deleted_at.eq(None::), - documents::updated_at.eq(now), - )) - .execute(&mut conn)?; - document.deleted_at = None; - document.updated_at = now; - } - - let tags_map = load_tags_for_documents(&mut conn, &[document.id])?; - let tags = tags_map.get(&document.id).cloned(); - return Ok(Json(DocumentDetailResponse { - document: to_document_response(document, tags), - current_version: to_version_response(version), - })); + let outcome = match process_upload(&state, request).await { + Ok(outcome) => outcome, + Err(err) => { + error!(error = ?err, "document upload failed"); + return Err(err); } + }; + info!( + document_id = %outcome.detail.document.id, + original_name = %outcome.detail.document.original_name, + created = outcome.created, + "document upload succeeded" + ); + let status = if outcome.created { + StatusCode::CREATED + } else { + StatusCode::OK + }; + + Ok((status, Json(outcome.detail))) +} + +pub async fn request_document_assets( + State(state): State, + Path(document_id): Path, + Query(query): Query, +) -> AppResult { + let mut conn = state.db()?; + let document: Document = documents::table.find(document_id).first(&mut conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); } - state - .storage - .put_object(&s3_key, file_bytes.clone(), content_type.clone()) - .await - .map_err(|err| AppError::internal(format!("failed to store document: {err}")))?; + let version: DocumentVersion = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::version_number.eq(document.current_version)) + .first(&mut conn)?; - let metadata_value = if metadata.is_null() { - Value::Object(Default::default()) - } else { - metadata - }; + enqueue_job( + &mut conn, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": version.id, + "force": query.force, + }), + None, + ) + .map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?; - let (document, version) = { - let mut conn = state.db()?; - conn.transaction(|conn| { - let new_document = NewDocument { - id: doc_id, - filename: stored_filename.clone(), - original_name: original_name.clone(), - content_type: content_type.clone(), - folder_id, - current_version: version_number, - metadata: metadata_value.clone(), - }; - diesel::insert_into(documents::table) - .values(&new_document) - .execute(conn)?; + Ok(StatusCode::ACCEPTED) +} - let new_version = NewDocumentVersion { - id: version_id, - document_id: doc_id, - version_number, - s3_key: s3_key.clone(), - size_bytes, - checksum: checksum_hex.clone(), - operations_summary: Value::Object(Default::default()), - }; +pub async fn reanalyze_all_documents( + State(state): State, + _user: AuthenticatedUser, +) -> AppResult<(StatusCode, Json)> { + let mut conn = state.db()?; - diesel::insert_into(document_versions::table) - .values(&new_version) - .execute(conn)?; + let targets: Vec<(Uuid, Uuid)> = document_versions::table + .inner_join(documents::table.on(document_versions::document_id.eq(documents::id))) + .filter(documents::deleted_at.is_null()) + .filter(document_versions::version_number.eq(documents::current_version)) + .select((documents::id, document_versions::id)) + .load(&mut conn)?; - let document: Document = documents::table.find(doc_id).first(conn)?; - let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?; + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + &mut conn, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": true, + }), + None, + ) + .map_err(|err| AppError::internal(format!("failed to enqueue analyze job: {err}")))?; + queued += 1; + } - Ok::<_, diesel::result::Error>((document, version)) - })? - }; + Ok((StatusCode::ACCEPTED, Json(BulkReanalyzeResponse { queued }))) +} - let response = DocumentDetailResponse { - document: to_document_response(document, None), - current_version: to_version_response(version), - }; +pub async fn list_document_assets( + State(state): State, + Path(document_id): Path, +) -> AppResult>> { + let mut conn = state.db()?; + let document: Document = documents::table.find(document_id).first(&mut conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } - Ok(Json(response)) + let version: DocumentVersion = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::version_number.eq(document.current_version)) + .first(&mut conn)?; + drop(conn); + + let assets = load_asset_responses(&state, version.id).await?; + Ok(Json(assets)) } pub async fn download_document( @@ -449,6 +514,157 @@ pub async fn remove_tag( Ok(StatusCode::NO_CONTENT) } +async fn process_upload(state: &AppState, request: UploadRequest) -> AppResult { + let UploadRequest { + bytes, + original_name, + content_type, + folder_id, + metadata, + } = request; + + if let Some(folder) = folder_id { + ensure_folder_exists(state, folder)?; + } + + let doc_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let version_number = 1; + let stored_filename = original_name.clone(); + + let checksum = Sha256::digest(&bytes); + let checksum_hex = hex::encode(checksum); + let size_bytes = bytes.len() as i64; + let s3_key = format!("documents/{doc_id}/v{version_number}/{version_id}"); + + { + let mut conn = state.db()?; + + let existing = documents::table + .inner_join( + document_versions::table.on(document_versions::document_id + .eq(documents::id) + .and(document_versions::version_number.eq(documents::current_version))), + ) + .filter(document_versions::checksum.eq(&checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(&mut conn) + .optional()?; + + if let Some((mut document, version)) = existing { + if document.deleted_at.is_some() { + let now = Utc::now().naive_utc(); + diesel::update(documents::table.find(document.id)) + .set(( + documents::deleted_at.eq(None::), + documents::updated_at.eq(now), + )) + .execute(&mut conn)?; + document.deleted_at = None; + document.updated_at = now; + } + + let tags_map = load_tags_for_documents(&mut conn, &[document.id])?; + let tags = tags_map.get(&document.id).cloned(); + drop(conn); + let assets = load_asset_responses(state, version.id).await?; + let thumbnail = assets + .iter() + .find(|asset| asset.asset_type == "thumbnail") + .cloned(); + + return Ok(UploadOutcome { + detail: DocumentDetailResponse { + document: to_document_response(document, tags, thumbnail), + current_version: to_version_response(version), + assets, + }, + created: false, + }); + } + } + + state + .storage + .put_object(&s3_key, bytes.clone(), content_type.clone()) + .await + .map_err(|err| { + error!(error = %err, key = %s3_key, "failed to store document"); + AppError::internal(format!("failed to store document: {err}")) + })?; + + let metadata_value = if metadata.is_null() { + Value::Object(Default::default()) + } else { + metadata + }; + + let (document, version) = { + let mut conn = state.db()?; + conn.transaction(|conn| { + let new_document = NewDocument { + id: doc_id, + filename: stored_filename.clone(), + original_name: original_name.clone(), + content_type: content_type.clone(), + folder_id, + current_version: version_number, + metadata: metadata_value.clone(), + }; + diesel::insert_into(documents::table) + .values(&new_document) + .execute(conn)?; + + let new_version = NewDocumentVersion { + id: version_id, + document_id: doc_id, + version_number, + s3_key: s3_key.clone(), + size_bytes, + checksum: checksum_hex.clone(), + operations_summary: Value::Object(Default::default()), + }; + + diesel::insert_into(document_versions::table) + .values(&new_version) + .execute(conn)?; + + let document: Document = documents::table.find(doc_id).first(conn)?; + let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?; + + Ok::<_, diesel::result::Error>((document, version)) + })? + }; + + let detail = DocumentDetailResponse { + document: to_document_response(document, None, None), + current_version: to_version_response(version.clone()), + assets: Vec::new(), + }; + + if let Ok(mut conn) = state.db() { + if let Err(err) = enqueue_job( + &mut conn, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": doc_id, + "document_version_id": version.id, + "force": false, + }), + None, + ) { + warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job"); + } + } else { + warn!(document_id = %doc_id, "failed to enqueue analyze job due to pool error"); + } + + Ok(UploadOutcome { + detail, + created: true, + }) +} + fn ensure_folder_exists(state: &AppState, folder_id: Uuid) -> AppResult<()> { let mut conn = state.db()?; let exists: bool = diesel::select(exists(folders::table.filter(folders::id.eq(folder_id)))) @@ -480,7 +696,82 @@ pub(crate) fn load_tags_for_documents( Ok(map) } -pub(crate) fn to_document_response(doc: Document, tags: Option>) -> DocumentResponse { +pub(crate) async fn load_primary_thumbnails( + state: &AppState, + documents: &[Document], +) -> AppResult> { + if documents.is_empty() { + return Ok(HashMap::new()); + } + + let mut current_versions: HashMap = HashMap::new(); + let mut doc_ids = Vec::with_capacity(documents.len()); + for doc in documents { + current_versions.insert(doc.id, doc.current_version); + doc_ids.push(doc.id); + } + + let mut conn = state.db()?; + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq_any(&doc_ids)) + .load(&mut conn)?; + + let mut version_id_by_doc: HashMap = HashMap::new(); + let mut doc_id_by_version: HashMap = HashMap::new(); + for version in versions { + if let Some(current_number) = current_versions.get(&version.document_id) { + if *current_number == version.version_number { + version_id_by_doc.insert(version.document_id, version.id); + doc_id_by_version.insert(version.id, version.document_id); + } + } + } + + if version_id_by_doc.is_empty() { + return Ok(HashMap::new()); + } + + let version_ids: Vec = version_id_by_doc.values().copied().collect(); + + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .filter(document_assets::asset_type.eq("thumbnail")) + .order(( + document_assets::document_version_id.asc(), + document_assets::created_at.asc(), + )) + .load(&mut conn)?; + + drop(conn); + + let mut first_assets: HashMap = HashMap::new(); + for asset in assets { + if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) { + first_assets.entry(*doc_id).or_insert(asset); + } + } + + let mut responses = HashMap::with_capacity(first_assets.len()); + for (doc_id, asset) in first_assets { + let url = state + .storage + .presign_get_object( + &asset.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + ) + .await + .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; + responses.insert(doc_id, to_asset_response(asset, url)); + } + + Ok(responses) +} + +pub(crate) fn to_document_response( + doc: Document, + tags: Option>, + thumbnail: Option, +) -> DocumentResponse { DocumentResponse { id: doc.id, filename: doc.filename, @@ -497,6 +788,7 @@ pub(crate) fn to_document_response(doc: Document, tags: Option>) -> Doc .into_iter() .map(TagResponse::from) .collect(), + thumbnail, } } @@ -512,6 +804,45 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse { } } +fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse { + DocumentAssetResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + width: asset.width, + height: asset.height, + url, + created_at: to_iso(asset.created_at), + } +} + +async fn load_asset_responses( + state: &AppState, + version_id: Uuid, +) -> AppResult> { + let mut conn = state.db()?; + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .order(document_assets::created_at.asc()) + .load(&mut conn)?; + drop(conn); + + let mut responses = Vec::with_capacity(assets.len()); + for asset in assets { + let url = state + .storage + .presign_get_object( + &asset.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + ) + .await + .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; + responses.push(to_asset_response(asset, url)); + } + + Ok(responses) +} + pub(crate) fn to_iso(dt: NaiveDateTime) -> String { DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339() } diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 6c1cd45..55c00ac 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -12,7 +12,10 @@ use crate::models::{Document, Folder, NewFolder}; use crate::schema::{document_tags, documents, folders}; use crate::state::AppState; -use super::documents::{load_tags_for_documents, to_document_response, to_iso, DocumentResponse}; +use super::documents::{ + load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso, + DocumentResponse, +}; #[derive(Deserialize)] pub struct CreateFolderRequest { @@ -20,6 +23,12 @@ pub struct CreateFolderRequest { pub parent_id: Option, } +#[derive(Deserialize)] +pub struct EnsureFolderPathRequest { + pub parent_id: Option, + pub segments: Vec, +} + #[derive(Serialize)] pub struct FolderResponse { pub folder: FolderInfo, @@ -48,6 +57,70 @@ pub struct FolderInfo { pub updated_at: String, } +pub async fn ensure_folder_path( + State(state): State, + Json(payload): Json, +) -> AppResult> { + if payload.segments.is_empty() { + return Err(AppError::bad_request("segments must not be empty")); + } + + let mut conn = state.db()?; + + let target_folder = conn.transaction::(|conn| { + let mut current_parent = payload.parent_id; + let mut last_folder: Option = None; + + for raw_name in &payload.segments { + let name = raw_name.trim(); + if name.is_empty() { + return Err(AppError::bad_request("folder names must not be empty")); + } + + let existing: Option = if let Some(parent_id) = current_parent { + folders::table + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(name)) + .first(conn) + .optional()? + }; + + let folder = if let Some(folder) = existing { + folder + } else { + let path_cache = build_path_cache(conn, current_parent, name)?; + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.to_string(), + parent_id: current_parent, + path_cache, + }; + + diesel::insert_into(folders::table) + .values(&new_folder) + .execute(conn)?; + + folders::table.find(new_folder.id).first(conn)? + }; + + current_parent = Some(folder.id); + last_folder = Some(folder); + } + + last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string())) + })?; + + Ok(Json(FolderResponse { + folder: folder_to_info(target_folder), + })) +} + pub async fn create_folder( State(state): State, Json(payload): Json, @@ -127,12 +200,16 @@ pub async fn list_folder_contents( let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; + drop(conn); + + let thumbnails = load_primary_thumbnails(&state, &docs).await?; let documents = docs .into_iter() .map(|doc| { let tags = tags_map.get(&doc.id).cloned(); - to_document_response(doc, tags) + let thumbnail = thumbnails.get(&doc.id).cloned(); + to_document_response(doc, tags, thumbnail) }) .collect(); @@ -228,11 +305,15 @@ pub async fn search_documents( let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; + drop(conn); + + let thumbnails = load_primary_thumbnails(&state, &docs).await?; let response = docs .into_iter() .map(|doc| { let tags = tags_map.get(&doc.id).cloned(); - to_document_response(doc, tags) + let thumbnail = thumbnails.get(&doc.id).cloned(); + to_document_response(doc, tags, thumbnail) }) .collect(); diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 0fe9eff..84e3e0e 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -30,17 +30,23 @@ pub fn create_router(state: AppState) -> Router<()> { "/", get(documents::list_documents).post(documents::upload_document), ) + .route("/reanalyze", post(documents::reanalyze_all_documents)) .route( "/:id", get(documents::get_document).delete(documents::delete_document), ) .route("/:id/download", get(documents::download_document)) + .route( + "/:id/assets", + get(documents::list_document_assets).post(documents::request_document_assets), + ) .route("/:id/folder", patch(documents::move_document)) .route("/:id/tags", post(documents::assign_tags)) .route("/:id/tags/:tag_id", delete(documents::remove_tag)); let folders_routes = Router::new() .route("/", post(folders::create_folder)) + .route("/path", post(folders::ensure_folder_path)) .route("/:id", delete(folders::delete_folder)) .route("/:id/contents", get(folders::list_folder_contents)) .route("/:id/documents", get(folders::search_documents)); diff --git a/backend/src/schema.rs b/backend/src/schema.rs index ff809d6..2e0be20 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -1,5 +1,19 @@ // @generated automatically by Diesel CLI. +diesel::table! { + document_assets (id) { + id -> Uuid, + document_version_id -> Uuid, + asset_type -> Text, + s3_key -> Text, + mime_type -> Text, + width -> Nullable, + height -> Nullable, + metadata -> Jsonb, + created_at -> Timestamptz, + } +} + diesel::table! { document_tags (document_id, tag_id) { document_id -> Uuid, @@ -55,6 +69,20 @@ diesel::table! { } } +diesel::table! { + jobs (id) { + id -> Uuid, + job_type -> Text, + payload -> Jsonb, + status -> Text, + attempts -> Int4, + run_after -> Timestamptz, + last_error -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { tags (id) { id -> Uuid, @@ -80,6 +108,7 @@ diesel::table! { } } +diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_tags -> documents (document_id)); diesel::joinable!(document_tags -> tags (tag_id)); diesel::joinable!(document_tags -> users (assigned_by)); @@ -87,10 +116,12 @@ diesel::joinable!(document_versions -> documents (document_id)); diesel::joinable!(documents -> folders (folder_id)); diesel::allow_tables_to_appear_in_same_query!( + document_assets, document_tags, document_versions, documents, folders, + jobs, tags, users, ); diff --git a/backend/src/storage.rs b/backend/src/storage.rs index 0f4e31c..08a6397 100644 --- a/backend/src/storage.rs +++ b/backend/src/storage.rs @@ -16,6 +16,8 @@ pub trait ObjectStorage: Send + Sync + 'static { ) -> Result<()>; async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result; + + async fn get_object(&self, key: &str) -> Result>; } pub struct S3Storage { @@ -76,4 +78,25 @@ impl ObjectStorage for S3Storage { Ok(presigned.uri().to_string()) } + + async fn get_object(&self, key: &str) -> Result> { + let response = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .send() + .await + .context("failed to download object from S3")?; + + let bytes = response + .body + .collect() + .await + .context("failed to read object stream")? + .into_bytes() + .to_vec(); + + Ok(bytes) + } } diff --git a/backend/src/workers/analyze.rs b/backend/src/workers/analyze.rs new file mode 100644 index 0000000..caf6530 --- /dev/null +++ b/backend/src/workers/analyze.rs @@ -0,0 +1,165 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use diesel::prelude::*; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use tokio::task; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::{ + jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_GENERATE_THUMBNAILS}, + models::{Document, DocumentVersion}, + schema::{document_versions, documents}, + state::AppState, +}; + +use super::{JobExecution, JobHandler}; + +#[derive(Debug, Deserialize)] +struct AnalyzePayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + +pub struct AnalyzeDocumentJob; + +impl AnalyzeDocumentJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for AnalyzeDocumentJob { + fn job_type(&self) -> &'static str { + JOB_ANALYZE_DOCUMENT + } + + async fn handle(&self, state: Arc, job: crate::models::Job) -> JobExecution { + let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid analyze payload: {err}"), + } + } + }; + + let state_clone = state.clone(); + match task::spawn_blocking(move || analyze_document(state_clone, payload)).await { + Ok(Ok(execution)) => execution, + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "analyze job will retry"); + JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + } + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "analyze task panicked"); + JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("worker panicked: {join_err}"), + } + } + } + } +} + +fn analyze_document(state: Arc, payload: AnalyzePayload) -> Result { + let mut conn = state.db().map_err(|err| format!("{err:?}"))?; + + let version: DocumentVersion = document_versions::table + .find(payload.document_version_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + if version.document_id != payload.document_id { + return Err("document/version mismatch".into()); + } + + let document: Document = documents::table + .find(payload.document_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + let (supported, reason) = determine_thumbnail_support(&document); + + let mut summary_map = match version.operations_summary { + Value::Object(map) => map, + _ => Map::new(), + }; + summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported)); + if let Some(reason) = reason { + summary_map.insert("thumbnail_reason".to_string(), Value::String(reason)); + } else { + summary_map.remove("thumbnail_reason"); + } + + diesel::update(document_versions::table.find(version.id)) + .set(document_versions::operations_summary.eq(Value::Object(summary_map))) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + if supported { + let enqueue_result = enqueue_job( + &mut conn, + JOB_GENERATE_THUMBNAILS, + json!({ + "document_id": payload.document_id, + "document_version_id": payload.document_version_id, + "force": payload.force, + }), + None, + ); + + if let Err(err) = enqueue_result { + return Err(err.to_string()); + } + } + + Ok(JobExecution::Success) +} + +pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option) { + let supported_mimes: HashSet<&'static str> = [ + "image/jpeg", + "image/png", + "image/gif", + "image/tiff", + "image/bmp", + "image/webp", + "application/pdf", + ] + .into_iter() + .collect(); + + if let Some(ref content_type) = document.content_type { + if supported_mimes.contains(content_type.as_str()) { + return (true, None); + } + } + + if let Some(ext) = document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.to_ascii_lowercase()) + { + let supported_exts = [ + "jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf", + ]; + if supported_exts.contains(&ext.as_str()) { + return (true, None); + } + } + + ( + false, + Some("content type not supported for thumbnails".into()), + ) +} diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs new file mode 100644 index 0000000..8a0df40 --- /dev/null +++ b/backend/src/workers/mod.rs @@ -0,0 +1,131 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use tokio::time::sleep; +use tracing::{error, info, warn}; + +use crate::{ + jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError}, + models::Job, + state::AppState, +}; + +pub mod analyze; +pub mod thumbnails; + +#[derive(Debug)] +pub enum JobExecution { + Success, + Retry { delay: Duration, error: String }, + Failed { error: String }, +} + +#[async_trait] +pub trait JobHandler: Send + Sync { + fn job_type(&self) -> &'static str; + async fn handle(&self, state: Arc, job: Job) -> JobExecution; +} + +pub struct Worker { + state: Arc, + handlers: HashMap<&'static str, Arc>, + poll_interval: Duration, +} + +impl Worker { + pub fn new( + state: Arc, + handlers: Vec>, + poll_interval: Duration, + ) -> Self { + let map = handlers + .into_iter() + .map(|handler| (handler.job_type(), handler)) + .collect(); + Self { + state, + handlers: map, + poll_interval, + } + } + + pub async fn run(&self) { + info!("worker started"); + loop { + match self.tick().await { + Ok(true) => {} + Ok(false) => sleep(self.poll_interval).await, + Err(err) => { + error!(error = %err, "worker tick failed"); + sleep(self.poll_interval).await; + } + } + } + } + + async fn tick(&self) -> Result { + let job_types: Vec<&str> = self.handlers.keys().copied().collect(); + if job_types.is_empty() { + return Ok(false); + } + + let mut conn = match self.state.db() { + Ok(conn) => conn, + Err(err) => { + error!(?err, "failed to obtain database connection in worker"); + return Ok(false); + } + }; + + let job_opt = reserve_job(&mut conn, &job_types)?; + drop(conn); + + if let Some(job) = job_opt { + if let Some(handler) = self.handlers.get(job.job_type.as_str()) { + let result = handler.handle(self.state.clone(), job.clone()).await; + match result { + JobExecution::Success => { + if let Ok(mut conn) = self.state.db() { + mark_job_succeeded(&mut conn, job.id)?; + } else { + error!("failed to mark job succeeded due to pool error"); + } + } + JobExecution::Retry { delay, error } => { + warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry"); + if let Ok(mut conn) = self.state.db() { + retry_job_after(&mut conn, job.id, delay, &error)?; + } else { + error!("failed to requeue job for retry due to pool error"); + } + } + JobExecution::Failed { error } => { + error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed"); + if let Ok(mut conn) = self.state.db() { + mark_job_failed(&mut conn, job.id, &error)?; + } else { + error!("failed to mark job failed due to pool error"); + } + } + } + } else { + error!(job_type = %job.job_type, "no handler registered for job type"); + if let Ok(mut conn) = self.state.db() { + mark_job_failed(&mut conn, job.id, "no handler registered")?; + } else { + error!("failed to mark job failed for missing handler due to pool error"); + } + } + Ok(true) + } else { + Ok(false) + } + } +} + +pub fn default_handlers() -> Vec> { + vec![ + Arc::new(analyze::AnalyzeDocumentJob::new()), + Arc::new(thumbnails::GenerateThumbnailsJob::new()), + ] +} diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs new file mode 100644 index 0000000..cf46e76 --- /dev/null +++ b/backend/src/workers/thumbnails.rs @@ -0,0 +1,321 @@ +use std::{io::Cursor, panic, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{pg::upsert::excluded, prelude::*}; +use image::{ + codecs::png::PngEncoder, ColorType, GenericImageView, ImageEncoder, ImageFormat, ImageReader, +}; +use pdfium_render::prelude::*; +use serde::Deserialize; +use serde_json::json; +use tokio::task; +use tracing::{error, info, warn}; +use uuid::Uuid; + +use crate::{ + jobs::JOB_GENERATE_THUMBNAILS, + models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, + schema::{document_assets, document_versions, documents}, + state::AppState, +}; + +use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler}; + +const THUMBNAIL_WIDTH: u32 = 512; +const THUMBNAIL_HEIGHT: u32 = 512; +const THUMBNAIL_ASSET_TYPE: &str = "thumbnail"; + +#[derive(Debug, Deserialize)] +struct ThumbnailPayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + +pub struct GenerateThumbnailsJob; + +impl GenerateThumbnailsJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for GenerateThumbnailsJob { + fn job_type(&self) -> &'static str { + JOB_GENERATE_THUMBNAILS + } + + async fn handle(&self, state: Arc, job: crate::models::Job) -> JobExecution { + let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) { + Ok(p) => p, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid thumbnail payload: {err}"), + } + } + }; + + let state_clone = state.clone(); + let initial = + match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await + { + Ok(Ok(ctx)) => ctx, + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "thumbnail job will retry"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "thumbnail task panicked"); + return JobExecution::Retry { + delay: Duration::from_secs(60), + error: format!("worker panicked: {join_err}"), + }; + } + }; + + if initial.skip { + info!(job_id = %job.id, "thumbnails already exist; skipping"); + return JobExecution::Success; + } + + let bytes = match state.storage.get_object(&initial.version.s3_key).await { + Ok(bytes) => bytes, + Err(err) => { + warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err.to_string(), + }; + } + }; + + let generation = match generate_thumbnail(&initial.document, &bytes) { + Ok(result) => result, + Err(err) => { + return JobExecution::Failed { error: err }; + } + }; + + if let Err(err) = state + .storage + .put_object( + &generation.s3_key, + generation.image_bytes.clone(), + Some("image/png".into()), + ) + .await + { + warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err.to_string(), + }; + } + + let state_clone = state.clone(); + match task::spawn_blocking(move || { + persist_thumbnail_metadata(state_clone, &initial, &generation) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!(job_id = %job.id, error = %err, "failed to persist thumbnail metadata; retrying"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: err, + }; + } + Err(join_err) => { + error!(job_id = %job.id, error = %join_err, "thumbnail metadata update panicked"); + return JobExecution::Retry { + delay: Duration::from_secs(30), + error: format!("metadata update panic: {join_err}"), + }; + } + } + + JobExecution::Success + } +} + +struct ThumbnailContext { + document: Document, + version: DocumentVersion, + skip: bool, +} + +struct GeneratedThumbnail { + image_bytes: Vec, + width: Option, + height: Option, + s3_key: String, +} + +fn load_thumbnail_context( + state: Arc, + payload: &ThumbnailPayload, +) -> Result { + let mut conn = state.db().map_err(|err| format!("{err:?}"))?; + + let version: DocumentVersion = document_versions::table + .find(payload.document_version_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + if version.document_id != payload.document_id { + return Err("document/version mismatch".into()); + } + + let document: Document = documents::table + .find(payload.document_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + let existing: Option = document_assets::table + .filter(document_assets::document_version_id.eq(payload.document_version_id)) + .filter(document_assets::asset_type.eq(THUMBNAIL_ASSET_TYPE)) + .first(&mut conn) + .optional() + .map_err(|err| format!("{err:?}"))?; + + let (supported, _) = determine_thumbnail_support(&document); + if !supported { + return Err("thumbnail generation not supported for this document".into()); + } + + let skip = existing.is_some() && !payload.force; + + Ok(ThumbnailContext { + document, + version, + skip, + }) +} + +fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result { + let is_pdf = document + .content_type + .as_deref() + .map(|mime| mime == "application/pdf") + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }); + + let (png_bytes, width, height) = if is_pdf { + generate_pdf_thumbnail(bytes)? + } else { + generate_image_thumbnail(bytes)? + }; + + let s3_key = format!("thumbnails/{}/{}.png", document.id, Uuid::new_v4()); + + Ok(GeneratedThumbnail { + image_bytes: png_bytes, + width, + height, + s3_key, + }) +} + +fn generate_image_thumbnail(bytes: &[u8]) -> Result<(Vec, Option, Option), String> { + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|err| err.to_string())?; + let mut image = reader.decode().map_err(|err| err.to_string())?; + if image.width() > THUMBNAIL_WIDTH || image.height() > THUMBNAIL_HEIGHT { + image = image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT); + } + + let (width, height) = image.dimensions(); + let mut cursor = Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|err| err.to_string())?; + let buffer = cursor.into_inner(); + Ok((buffer, Some(width as i32), Some(height as i32))) +} + +fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec, Option, Option), String> { + let pdfium = panic::catch_unwind(|| Pdfium::default()) + .map_err(|_| "failed to initialize PDFium".to_string())?; + + let document = pdfium + .load_pdf_from_byte_slice(bytes, None) + .map_err(|err| format!("load pdf: {err}"))?; + + let page = document + .pages() + .get(0) + .map_err(|err| format!("load first page: {err}"))?; + + let render_config = PdfRenderConfig::new() + .set_target_width(THUMBNAIL_WIDTH as i32) + .set_maximum_height(THUMBNAIL_HEIGHT as i32) + .render_form_data(true) + .rotate_if_landscape(PdfPageRenderRotation::None, true); + + let bitmap = page + .render_with_config(&render_config) + .map_err(|err| format!("render pdf page: {err}"))?; + + let image = bitmap.as_image().to_rgb8(); + let (width, height) = image.dimensions(); + let mut cursor = Cursor::new(Vec::new()); + PngEncoder::new(&mut cursor) + .write_image(image.as_raw(), width, height, ColorType::Rgb8.into()) + .map_err(|err| format!("encode pdf thumbnail: {err}"))?; + + Ok((cursor.into_inner(), Some(width as i32), Some(height as i32))) +} + +fn persist_thumbnail_metadata( + state: Arc, + context: &ThumbnailContext, + generated: &GeneratedThumbnail, +) -> Result<(), String> { + let mut conn = state.db().map_err(|err| format!("{err:?}"))?; + + let new_asset = NewDocumentAsset { + id: Uuid::new_v4(), + document_version_id: context.version.id, + asset_type: THUMBNAIL_ASSET_TYPE.to_string(), + s3_key: generated.s3_key.clone(), + mime_type: "image/png".to_string(), + width: generated.width, + height: generated.height, + metadata: json!({ + "generated_at": Utc::now().to_rfc3339(), + }), + }; + + diesel::insert_into(document_assets::table) + .values(&new_asset) + .on_conflict(( + document_assets::document_version_id, + document_assets::asset_type, + )) + .do_update() + .set(( + document_assets::s3_key.eq(excluded(document_assets::s3_key)), + document_assets::mime_type.eq(excluded(document_assets::mime_type)), + document_assets::width.eq(excluded(document_assets::width)), + document_assets::height.eq(excluded(document_assets::height)), + document_assets::metadata.eq(excluded(document_assets::metadata)), + )) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(()) +} diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index db9d5bc..ef5f127 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -14,10 +14,7 @@ struct AuthenticatedUser { #[tokio::test] async fn login_and_me_roundtrip() -> Result<()> { let _lock = acquire_db_lock().await; - let Some(app) = TestApp::new().await? else { - eprintln!("skipping test: TEST_DATABASE_URL not set"); - return Ok(()); - }; + let app = TestApp::new().await?; let password = "s3cret"; app.insert_user("alice", password, "admin").await?; diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index d7705d2..6d38afa 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -17,7 +17,7 @@ use once_cell::sync::Lazy; use paperless_backend::auth::jwt::JwtService; use paperless_backend::config::AppConfig; use paperless_backend::db::{self, PgPool}; -use paperless_backend::models::NewUser; +use paperless_backend::models::{Job, NewUser}; use paperless_backend::routes; use paperless_backend::state::AppState; use paperless_backend::storage::ObjectStorage; @@ -70,6 +70,14 @@ impl ObjectStorage for FakeStorage { expires_in.as_secs() )) } + + async fn get_object(&self, key: &str) -> Result> { + let guard = self.objects.lock().await; + guard + .get(key) + .map(|obj| obj.bytes.clone()) + .ok_or_else(|| anyhow!("object {key} missing")) + } } impl FakeStorage { @@ -93,11 +101,9 @@ pub struct TestApp { } impl TestApp { - pub async fn new() -> Result> { - let database_url = match env::var("TEST_DATABASE_URL") { - Ok(url) => url, - Err(_) => return Ok(None), - }; + pub async fn new() -> Result { + let database_url = env::var("TEST_DATABASE_URL") + .context("TEST_DATABASE_URL must be set for integration tests")?; let config = AppConfig { database_url: database_url.clone(), @@ -123,11 +129,11 @@ impl TestApp { let state = AppState::new(pool.clone(), config, storage_for_state, jwt); let router = routes::create_router(state.clone()); - Ok(Some(Self { + Ok(Self { state, router, storage, - })) + }) } pub async fn cleanup(&self) -> Result<()> { @@ -199,6 +205,34 @@ impl TestApp { Ok(parsed.access_token) } + #[allow(dead_code)] + pub async fn clear_jobs(&self) -> Result<()> { + self.with_conn(|conn| { + use paperless_backend::schema::jobs::dsl::jobs as jobs_table; + diesel::delete(jobs_table) + .execute(conn) + .context("failed to clear jobs")?; + Ok(()) + }) + .await + } + + #[allow(dead_code)] + pub async fn jobs_by_type(&self, ty: &str) -> Result> { + let ty = ty.to_string(); + self.with_conn(move |conn| { + use paperless_backend::schema::jobs::dsl::{ + job_type as job_type_col, jobs as jobs_table, + }; + let rows = jobs_table + .filter(job_type_col.eq(&ty)) + .load::(conn) + .context("failed to load jobs")?; + Ok(rows) + }) + .await + } + pub async fn post_json( &self, path: &str, diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index e318adb..a26807f 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -11,6 +11,7 @@ use uuid::Uuid; struct DocumentDetail { document: DocumentInfo, current_version: DocumentVersion, + assets: Vec, } #[derive(Deserialize)] @@ -24,10 +25,17 @@ struct DocumentInfo { #[derive(Deserialize)] struct DocumentVersion { + id: Uuid, s3_key: String, size_bytes: i64, } +#[derive(Deserialize)] +struct DocumentAssetInfo { + id: Uuid, + asset_type: String, +} + #[derive(Deserialize)] struct DocumentListItem { id: Uuid, @@ -40,13 +48,23 @@ struct DocumentDownload { filename: String, } +#[derive(Deserialize)] +struct BulkReanalyze { + queued: usize, +} + +#[derive(Deserialize)] +struct AnalyzeJobPayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + #[tokio::test] async fn upload_and_list_document() -> Result<()> { let _lock = acquire_db_lock().await; - let Some(app) = TestApp::new().await? else { - eprintln!("skipping test: TEST_DATABASE_URL not set"); - return Ok(()); - }; + let app = TestApp::new().await?; let password = "passw0rd"; app.insert_user("dana", password, "admin").await?; @@ -63,7 +81,7 @@ async fn upload_and_list_document() -> Result<()> { &token, ) .await?; - assert_eq!(upload.status(), StatusCode::OK); + assert_eq!(upload.status(), StatusCode::CREATED); let body = body_to_vec(upload.into_body()).await?; let detail: DocumentDetail = serde_json::from_slice(&body)?; @@ -72,6 +90,7 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(detail.document.deleted_at, None); assert!(detail.document.tags.is_empty()); assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64); + assert!(detail.assets.is_empty()); let stored = app .storage() @@ -79,6 +98,7 @@ async fn upload_and_list_document() -> Result<()> { .await .expect("object stored"); assert_eq!(stored.bytes, file_bytes); + assert_eq!(app.storage().object_count().await, 1); let response = app.get("/api/documents", Some(&token)).await?; assert_eq!(response.status(), StatusCode::OK); @@ -108,10 +128,7 @@ async fn upload_and_list_document() -> Result<()> { #[tokio::test] async fn duplicate_and_restore_document() -> Result<()> { let _lock = acquire_db_lock().await; - let Some(app) = TestApp::new().await? else { - eprintln!("skipping test: TEST_DATABASE_URL not set"); - return Ok(()); - }; + let app = TestApp::new().await?; let password = "pass1234"; app.insert_user("sam", password, "admin").await?; @@ -128,6 +145,7 @@ async fn duplicate_and_restore_document() -> Result<()> { &token, ) .await?; + assert_eq!(first.status(), StatusCode::CREATED); let first_body = body_to_vec(first.into_body()).await?; let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; @@ -147,6 +165,8 @@ async fn duplicate_and_restore_document() -> Result<()> { assert_eq!(first_detail.document.id, second_detail.document.id); assert_eq!(second_detail.document.deleted_at, None); + assert!(second_detail.assets.is_empty()); + assert_eq!(app.storage().object_count().await, 1); let delete = app .delete( @@ -166,11 +186,89 @@ async fn duplicate_and_restore_document() -> Result<()> { &token, ) .await?; + assert_eq!(third.status(), StatusCode::OK); let third_body = body_to_vec(third.into_body()).await?; let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; assert_eq!(third_detail.document.id, first_detail.document.id); assert_eq!(third_detail.document.deleted_at, None); + assert_eq!(app.storage().object_count().await, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_reanalyze_documents() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulkpass"; + app.insert_user("alex", password, "admin").await?; + let token = app.login_token("alex", password).await?; + + app.clear_jobs().await?; + + let first_bytes = b"first doc"; + let first = app + .upload_document( + "/api/documents", + "first.txt", + "text/plain", + first_bytes, + None, + &token, + ) + .await?; + assert_eq!(first.status(), StatusCode::CREATED); + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second_bytes = b"second doc"; + let second = app + .upload_document( + "/api/documents", + "second.txt", + "text/plain", + second_bytes, + None, + &token, + ) + .await?; + assert_eq!(second.status(), StatusCode::CREATED); + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + app.clear_jobs().await?; + + let response = app + .post_json( + "/api/documents/reanalyze", + &serde_json::json!({}), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = body_to_vec(response.into_body()).await?; + let bulk: BulkReanalyze = serde_json::from_slice(&body)?; + assert_eq!(bulk.queued, 2); + + let jobs = app.jobs_by_type("analyze-document").await?; + assert_eq!(jobs.len(), 2); + let mut payload_docs = Vec::new(); + for job in jobs { + let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?; + assert!(payload.force); + payload_docs.push((payload.document_id, payload.document_version_id)); + } + + let mut expected = vec![ + (first_detail.document.id, first_detail.current_version.id), + (second_detail.document.id, second_detail.current_version.id), + ]; + payload_docs.sort(); + expected.sort(); + assert_eq!(payload_docs, expected); app.cleanup().await?; Ok(()) diff --git a/backend/tests/folders_flow.rs b/backend/tests/folders_flow.rs index b8c238a..3935408 100644 --- a/backend/tests/folders_flow.rs +++ b/backend/tests/folders_flow.rs @@ -33,6 +33,12 @@ struct CreateFolder<'a> { parent_id: Option, } +#[derive(Serialize)] +struct EnsureFolderPath<'a> { + parent_id: Option, + segments: &'a [&'a str], +} + #[derive(Serialize)] struct MoveDocumentRequest { folder_id: Option, @@ -46,10 +52,7 @@ struct DocumentDetail { #[tokio::test] async fn folder_move_and_delete_flow() -> Result<()> { let _lock = acquire_db_lock().await; - let Some(app) = TestApp::new().await? else { - eprintln!("skipping test: TEST_DATABASE_URL not set"); - return Ok(()); - }; + let app = TestApp::new().await?; let password = "folderpass"; app.insert_user("folder-admin", password, "admin").await?; @@ -135,3 +138,80 @@ async fn folder_move_and_delete_flow() -> Result<()> { app.cleanup().await?; Ok(()) } + +#[tokio::test] +async fn ensure_path_creates_nested_folders() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pathpass"; + app.insert_user("path-admin", password, "admin").await?; + let token = app.login_token("path-admin", password).await?; + + let base_path = EnsureFolderPath { + parent_id: None, + segments: &["Team", "Engineering", "Backend"], + }; + let first_resp = app + .post_json("/api/folders/path", &base_path, Some(&token)) + .await?; + assert_eq!(first_resp.status(), StatusCode::OK); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders/path", &base_path, Some(&token)) + .await?; + assert_eq!(second_resp.status(), StatusCode::OK); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + assert_eq!(second_folder.folder.id, first_folder.folder.id); + + let engineering_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: None, + segments: &["Team", "Engineering"], + }, + Some(&token), + ) + .await?; + assert_eq!(engineering_resp.status(), StatusCode::OK); + let engineering_body = body_to_vec(engineering_resp.into_body()).await?; + let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?; + assert_ne!(engineering_folder.folder.id, first_folder.folder.id); + + let infra_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: Some(engineering_folder.folder.id), + segments: &["Infrastructure"], + }, + Some(&token), + ) + .await?; + assert_eq!(infra_resp.status(), StatusCode::OK); + let infra_body = body_to_vec(infra_resp.into_body()).await?; + let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?; + assert_ne!(infra_folder.folder.id, engineering_folder.folder.id); + + let infra_dupe_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: Some(engineering_folder.folder.id), + segments: &["Infrastructure"], + }, + Some(&token), + ) + .await?; + assert_eq!(infra_dupe_resp.status(), StatusCode::OK); + let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?; + let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?; + assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index 4d060ec..cd7468c 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -36,10 +36,7 @@ struct AssignTagsRequest { #[tokio::test] async fn tag_assignment_flow() -> Result<()> { let _lock = acquire_db_lock().await; - let Some(app) = TestApp::new().await? else { - eprintln!("skipping test: TEST_DATABASE_URL not set"); - return Ok(()); - }; + let app = TestApp::new().await?; let password = "tagpass"; app.insert_user("tagger", password, "admin").await?; @@ -55,6 +52,7 @@ async fn tag_assignment_flow() -> Result<()> { &token, ) .await?; + assert_eq!(upload.status(), StatusCode::CREATED); let upload_body = body_to_vec(upload.into_body()).await?; let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..73de8d7 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,16 @@ +services: + postgres-test: + image: postgres:16-alpine + environment: + POSTGRES_USER: paperless + POSTGRES_PASSWORD: paperless_test + POSTGRES_DB: paperless_test + ports: + - "5433:5432" + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U paperless"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..6f9f81d --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,27 @@ +# Integration Test Setup + +The backend integration tests talk to a real Postgres database. To spin up an ephemeral instance locally, use the dedicated compose file: + +```bash +docker compose -f docker-compose.test.yml up -d +``` + +This starts Postgres on port `5433` with the database/user both named `paperless` and password `paperless_test`. Point the test harness at it: + +```bash +export TEST_DATABASE_URL=postgres://paperless:paperless_test@localhost:5433/paperless_test +``` + +Run the tests as usual: + +```bash +cargo test +``` + +When you are done, stop the container: + +```bash +docker compose -f docker-compose.test.yml down +``` + +The compose file uses a tmpfs volume, so each run starts with a clean database. diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..9cc4abc --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,16 @@ +module.exports = { + presets: [ + [ + '@babel/preset-env', + { + targets: 'defaults', + }, + ], + [ + '@babel/preset-react', + { + runtime: 'automatic', + }, + ], + ], +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44c202b..9e8cd18 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,15 @@ "name": "paperless-neo-frontend", "version": "0.1.0", "dependencies": { - "axios": "1.7.7" + "axios": "1.7.7", + "react": "18.3.1", + "react-dom": "18.3.1" }, "devDependencies": { + "@babel/core": "7.26.0", + "@babel/preset-env": "7.26.0", + "@babel/preset-react": "7.26.3", + "babel-loader": "9.2.1", "css-loader": "7.1.2", "dotenv": "16.4.5", "html-webpack-plugin": "5.6.3", @@ -20,6 +26,1747 @@ "webpack-dev-server": "5.1.0" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "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/core/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/@babel/core/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/@babel/core/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/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@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-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/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/@babel/helper-create-class-features-plugin": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz", + "integrity": "sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/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/@babel/helper-create-regexp-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/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/@babel/helper-define-polyfill-provider": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.10" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/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/@babel/helper-define-polyfill-provider/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/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz", + "integrity": "sha512-zdf983tNfLZFletc0RRXYrHrucBEg95NIFMkn6K9dbeMYnsgHaSBGcQqdsCSStG2PYwRre0Qc2NNSCXbG+xc6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", + "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.4.tgz", + "integrity": "sha512-1yxmvN0MJHOhPVmAsmoW5liWwoILobu/d/ShymZmj867bAdxGbehIrew1DuLpw2Ukv+qDSSPQdYW1dLNE7t11A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", + "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.3", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz", + "integrity": "sha512-cFOlhIYPBv/iBoc+KS3M6et2XPtbT2HiCRfBXWtfpc9OAyostldxIf9YAYB6ypURBBbx+Qv6nyrLzASfJe+hBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.4.tgz", + "integrity": "sha512-373KA2HQzKhQCYiRVIRr+3MjpCObqzDlyrM6u4I201wL8Mp2wHf7uB8GhDwis03k2ti8Zr65Zyyqs1xOxUF/Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", + "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", + "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", + "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz", + "integrity": "sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/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/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.26.3.tgz", + "integrity": "sha512-Nl03d6T9ky516DGK2YMxrTqvnpUW63TnJMOMonj+Zae0JiPC5BC9xPMSL6L8fiSpA5vP88qfygavVQvnLp+6Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-transform-react-display-name": "^7.25.9", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/plugin-transform-react-jsx-development": "^7.25.9", + "@babel/plugin-transform-react-pure-annotations": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/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/@babel/traverse/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/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", @@ -835,6 +2582,133 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-loader": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-loader/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/babel-loader/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/babel-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-loader/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/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/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.14.tgz", @@ -1142,6 +3016,13 @@ "node": ">= 12" } }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true, + "license": "ISC" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -1207,6 +3088,13 @@ "node": ">= 0.6" } }, + "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/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -1224,6 +3112,20 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js-compat": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", + "integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.26.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1712,6 +3614,16 @@ "node": ">=4.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1872,6 +3784,114 @@ "node": ">= 0.8" } }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-cache-dir/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/find-cache-dir/node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1976,6 +3996,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2618,6 +4648,25 @@ "node": ">= 10.13.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==", + "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/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -2632,6 +4681,19 @@ "dev": true, "license": "MIT" }, + "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/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -2687,6 +4749,25 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -2697,6 +4778,16 @@ "tslib": "^2.0.3" } }, + "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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3366,6 +5457,31 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3407,6 +5523,64 @@ "node": ">= 10.13.0" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -3543,6 +5717,15 @@ "dev": true, "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -4293,6 +6476,50 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4850,6 +7077,26 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 2438ed1..1957ce4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,15 @@ "lint": "echo \"No linting configured\"" }, "dependencies": { - "axios": "1.7.7" + "axios": "1.7.7", + "react": "18.3.1", + "react-dom": "18.3.1" }, "devDependencies": { + "@babel/core": "7.26.0", + "@babel/preset-env": "7.26.0", + "@babel/preset-react": "7.26.3", + "babel-loader": "9.2.1", "css-loader": "7.1.2", "dotenv": "16.4.5", "html-webpack-plugin": "5.6.3", diff --git a/frontend/src/index.js b/frontend/src/index.js deleted file mode 100644 index 622b9b7..0000000 --- a/frontend/src/index.js +++ /dev/null @@ -1,1094 +0,0 @@ -import './styles.css'; -import axios from 'axios'; - -const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, ''); -const api = axios.create({ - baseURL: API_ROOT ? `${API_ROOT}/api` : '/api', - headers: { - 'Content-Type': 'application/json', - }, -}); - -const state = { - token: window.localStorage.getItem('paperless_token') || '', - folderNodes: new Map([ - [ - 'root', - { - id: 'root', - name: 'All Documents', - parentId: null, - children: [], - expanded: true, - loaded: false, - }, - ], - ]), - folderContents: new Map(), - selectedFolder: 'root', - currentFolder: null, - currentSubfolders: [], - documents: [], - selectedDocumentId: null, - documentDetails: new Map(), - searchResults: null, - tags: [], - searchQuery: '', - activeTagFilters: new Set(), - status: null, - loading: false, - draggedDocumentId: null, -}; - -let dragAndDropInitialized = false; -let dropOverlay; -let dropOverlayLabel; -let dragCounter = 0; - -const hasFiles = (event) => - Array.from(event.dataTransfer?.types || []).includes('Files'); - -api.interceptors.request.use((config) => { - if (state.token) { - config.headers.Authorization = `Bearer ${state.token}`; - } - return config; -}); - -function setStatus(message, variant = 'info') { - state.status = message ? { message, variant } : null; - renderApp(); -} - -function setLoading(value) { - state.loading = value; - renderApp(); -} - -function getCurrentFolderName() { - if (state.selectedFolder === 'root' || !state.currentFolder) { - return 'All Documents'; - } - return state.currentFolder.name; -} - -function upsertFolderNode(folder) { - const parentId = folder.parent_id || 'root'; - let node = state.folderNodes.get(folder.id); - if (!node) { - node = { - id: folder.id, - name: folder.name, - parentId, - children: [], - expanded: false, - loaded: false, - }; - state.folderNodes.set(folder.id, node); - } - node.name = folder.name; - node.parentId = parentId; - return node; -} - -async function ensureFolderData(folderId, { force = false } = {}) { - if (!force && state.folderContents.has(folderId)) { - return state.folderContents.get(folderId); - } - - const path = folderId === 'root' ? 'root' : folderId; - const { data } = await api.get(`/folders/${path}/contents`); - - const node = state.folderNodes.get(folderId) || { - id: folderId, - name: data.folder?.name || 'Folder', - parentId: data.folder?.parent_id || 'root', - children: [], - expanded: folderId === 'root', - loaded: false, - }; - - node.loaded = true; - node.children = (data.subfolders || []).map((child) => { - const childNode = upsertFolderNode(child); - return childNode.id; - }); - state.folderNodes.set(folderId, node); - - state.folderContents.set(folderId, data); - if (state.selectedFolder === folderId) { - applySelectedFolder(folderId, data); - } - return data; -} - -function applySelectedFolder(folderId, contents) { - state.currentSubfolders = contents.subfolders || []; - const docs = contents.documents || []; - if (contents.subfolders) { - contents.subfolders.forEach(upsertFolderNode); - } - - state.documents = docs.map((doc) => { - const detail = state.documentDetails.get(doc.id); - if (detail) { - detail.document = doc; - state.documentDetails.set(doc.id, detail); - } - return doc; - }); - state.currentFolder = contents.folder || null; - if (!state.documents.find((doc) => doc.id === state.selectedDocumentId)) { - state.selectedDocumentId = state.documents.length ? state.documents[0].id : null; - } -} - -function isFilterActive() { - return state.searchQuery.trim().length > 0 || state.activeTagFilters.size > 0; -} - -async function selectFolder(folderId) { - state.selectedFolder = folderId; - setLoading(true); - try { - const contents = await ensureFolderData(folderId, { force: true }); - applySelectedFolder(folderId, contents); - if (state.selectedDocumentId) { - await ensureDocumentDetail(state.selectedDocumentId, { force: true }); - } - state.searchResults = null; - } catch (error) { - console.error(error); - setStatus('Failed to load folder contents.', 'error'); - } finally { - setLoading(false); - if (isFilterActive()) { - applyFiltersAndRender(); - } else { - renderApp(); - } - } -} - -async function refreshCurrentFolder() { - try { - const contents = await ensureFolderData(state.selectedFolder, { force: true }); - applySelectedFolder(state.selectedFolder, contents); - if (state.selectedDocumentId) { - await ensureDocumentDetail(state.selectedDocumentId, { force: true }); - } - if (isFilterActive()) { - await applyFiltersAndRender(); - } else { - renderApp(); - } - } catch (error) { - console.error(error); - setStatus('Failed to refresh folder.', 'error'); - } -} - -async function refreshTags() { - try { - const { data } = await api.get('/tags'); - state.tags = data || []; - } catch (error) { - console.error(error); - setStatus('Unable to load tags.', 'error'); - } -} - -async function ensureDocumentDetail(documentId, { force = false } = {}) { - if (!force && state.documentDetails.has(documentId)) { - return state.documentDetails.get(documentId); - } - const { data } = await api.get(`/documents/${documentId}`); - state.documentDetails.set(documentId, data); - const index = state.documents.findIndex((doc) => doc.id === documentId); - if (index !== -1) { - state.documents[index] = data.document; - } - if (state.searchResults) { - state.searchResults = state.searchResults.map((doc) => - doc.id === documentId ? data.document : doc - ); - } - return data; -} - -function setupGlobalDragAndDrop() { - if (dragAndDropInitialized) return; - dragAndDropInitialized = true; - - dropOverlay = document.createElement('div'); - dropOverlay.className = 'drop-overlay'; - dropOverlay.innerHTML = ` -
- Drop files to upload to All Documents -
- `; - dropOverlayLabel = dropOverlay.querySelector('#drop-folder-name'); - document.body.appendChild(dropOverlay); - - window.addEventListener('dragenter', (event) => { - if (!state.token || !hasFiles(event)) return; - event.preventDefault(); - dragCounter += 1; - dropOverlayLabel.textContent = getCurrentFolderName(); - dropOverlay.classList.add('active'); - }); - - window.addEventListener('dragover', (event) => { - if (!state.token || !dropOverlay.classList.contains('active')) return; - if (hasFiles(event)) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'copy'; - } - }); - - const maybeHideOverlay = () => { - dragCounter = Math.max(0, dragCounter - 1); - if (dragCounter === 0) { - dropOverlay.classList.remove('active'); - } - }; - - window.addEventListener('dragleave', (event) => { - if (!state.token || !hasFiles(event)) return; - maybeHideOverlay(); - }); - - window.addEventListener('drop', (event) => { - if (!state.token || !dropOverlay.classList.contains('active')) return; - event.preventDefault(); - dropOverlay.classList.remove('active'); - dragCounter = 0; - const files = Array.from(event.dataTransfer.files || []); - if (files.length) { - handleFileDrop(files, state.selectedFolder); - } - }); -} - -async function initializeAfterLogin() { - setLoading(true); - try { - await Promise.all([ensureFolderData('root', { force: true }), refreshTags()]); - await selectFolder('root'); - } catch (error) { - console.error(error); - setStatus('Failed to initialize data.', 'error'); - } finally { - setLoading(false); - } -} - -async function handleLogin(event) { - event.preventDefault(); - const form = new FormData(event.currentTarget); - const payload = { - username: form.get('username')?.trim(), - password: form.get('password') || '', - }; - if (!payload.username || !payload.password) { - setStatus('Username and password are required.', 'error'); - return; - } - try { - setLoading(true); - const { data } = await api.post('/auth/login', payload); - state.token = data.access_token; - window.localStorage.setItem('paperless_token', state.token); - setStatus('Login successful.', 'success'); - await initializeAfterLogin(); - } catch (error) { - console.error(error); - setStatus('Login failed. Check credentials.', 'error'); - } finally { - setLoading(false); - renderApp(); - } -} - -function handleLogout() { - state.token = ''; - state.documents = []; - state.currentFolder = null; - state.currentSubfolders = []; - state.selectedDocumentId = null; - state.documentDetails.clear(); - state.searchResults = null; - window.localStorage.removeItem('paperless_token'); - setStatus('Logged out.', 'info'); - renderApp(); -} - -async function uploadFile(file, targetFolderId) { - const formData = new FormData(); - formData.append('file', file); - if (targetFolderId && targetFolderId !== 'root') { - formData.append('folder_id', targetFolderId); - } - try { - await api.post('/documents', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - setStatus(`Uploaded ${file.name}`, 'success'); - } catch (error) { - console.error(error); - const message = error.response?.data?.error || `Failed to upload ${file.name}.`; - setStatus(message, 'error'); - } -} - -async function handleFileDrop(files, targetFolderId) { - if (!state.token) { - setStatus('Please log in before uploading.', 'error'); - return; - } - setLoading(true); - try { - for (const file of files) { - // eslint-disable-next-line no-await-in-loop - await uploadFile(file, targetFolderId); - } - await refreshCurrentFolder(); - } finally { - setLoading(false); - } -} - -async function handleDownload(documentId) { - try { - setLoading(true); - const { data } = await api.get(`/documents/${documentId}/download`); - window.open(data.url, '_blank'); - setStatus('Download link opened in a new tab.', 'success'); - } catch (error) { - console.error(error); - setStatus('Unable to fetch download link.', 'error'); - } finally { - setLoading(false); - } -} - -async function moveDocumentToFolder(documentId, targetFolderId) { - const target = targetFolderId === 'root' ? null : targetFolderId; - try { - await api.patch(`/documents/${documentId}/folder`, { folder_id: target }); - setStatus('Document moved.', 'success'); - state.documentDetails.delete(documentId); - await Promise.all([ - refreshCurrentFolder(), - ensureFolderData(targetFolderId || 'root', { force: true }), - ]); - } catch (error) { - console.error(error); - setStatus('Failed to move document.', 'error'); - } -} - -function handleDocumentDragStart(event, documentId) { - state.draggedDocumentId = documentId; - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('application/x-paperless-doc', documentId); - event.currentTarget.classList.add('dragging'); -} - -function handleDocumentDragEnd(event) { - state.draggedDocumentId = null; - event.currentTarget.classList.remove('dragging'); -} - -function handleFolderDragOver(event, folderId) { - const row = event.currentTarget; - if (hasFiles(event)) { - event.preventDefault(); - row.classList.add('is-drop-target'); - return; - } - if (!state.draggedDocumentId || folderId === undefined) return; - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - row.classList.add('is-drop-target'); -} - -function handleFolderDragLeave(event) { - event.currentTarget.classList.remove('is-drop-target'); -} - -function handleFolderDrop(event, folderId) { - event.preventDefault(); - const row = event.currentTarget; - row.classList.remove('is-drop-target'); - - const files = Array.from(event.dataTransfer.files || []); - if (files.length) { - handleFileDrop(files, folderId === undefined ? 'root' : folderId); - return; - } - - const draggedId = - state.draggedDocumentId || event.dataTransfer.getData('application/x-paperless-doc'); - if (!draggedId) return; - if (folderId === state.selectedFolder) return; - moveDocumentToFolder(draggedId, folderId === undefined ? 'root' : folderId); -} - -async function handleFolderDelete(folderId) { - if (!state.token) { - setStatus('Log in to manage folders.', 'error'); - return; - } - if (folderId === 'root') { - setStatus('The root folder cannot be removed.', 'error'); - return; - } - - const node = state.folderNodes.get(folderId); - if (!node) { - setStatus('Folder not found.', 'error'); - return; - } - - setLoading(true); - try { - const contents = await ensureFolderData(folderId, { force: true }); - const hasChildren = (contents.subfolders || []).length > 0; - const hasDocuments = (contents.documents || []).length > 0; - - if (hasChildren || hasDocuments) { - setStatus('Folder must be empty before it can be deleted.', 'error'); - return; - } - - await api.delete(`/folders/${folderId}`); - - const parentId = node.parentId || 'root'; - state.folderNodes.delete(folderId); - state.folderContents.delete(folderId); - - const parentNode = state.folderNodes.get(parentId); - if (parentNode) { - parentNode.children = parentNode.children.filter((id) => id !== folderId); - } - - if (state.selectedFolder === folderId) { - state.selectedFolder = parentId; - state.searchResults = null; - const parentContents = await ensureFolderData(parentId, { force: true }); - applySelectedFolder(parentId, parentContents); - } else if (state.selectedFolder === parentId) { - const parentContents = await ensureFolderData(parentId, { force: true }); - applySelectedFolder(parentId, parentContents); - } else { - await ensureFolderData(parentId, { force: true }); - } - - setStatus('Folder deleted.', 'success'); - } catch (error) { - console.error(error); - const message = error.response?.data?.error || 'Failed to delete folder.'; - setStatus(message, 'error'); - } finally { - setLoading(false); - } -} - -async function selectDocument(documentId) { - state.selectedDocumentId = documentId; - renderApp(); - try { - await ensureDocumentDetail(documentId); - renderApp(); - } catch (error) { - console.error(error); - setStatus('Failed to load document details.', 'error'); - } -} - -async function handleTagRemove(documentId, tagId) { - try { - await api.delete(`/documents/${documentId}/tags/${tagId}`); - setStatus('Tag removed.', 'success'); - await Promise.all([ - refreshCurrentFolder(), - refreshTags(), - ensureDocumentDetail(documentId, { force: true }), - ]); - } catch (error) { - console.error(error); - setStatus('Failed to remove tag.', 'error'); - } -} - -async function handleTagAdd(event, document) { - event.preventDefault(); - const input = event.currentTarget.querySelector('input[name="tag"]'); - const label = input.value.trim(); - if (!label) return; - let tag = state.tags.find((t) => t.label.toLowerCase() === label.toLowerCase()); - try { - if (!tag) { - const { data } = await api.post('/tags', { label, color: null }); - tag = data; - await refreshTags(); - } - await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); - setStatus('Tag assigned.', 'success'); - input.value = ''; - await Promise.all([ - refreshCurrentFolder(), - ensureDocumentDetail(document.id, { force: true }), - ]); - } catch (error) { - console.error(error); - setStatus('Failed to assign tag.', 'error'); - } -} - -function handleSearchInput(event) { - state.searchQuery = event.target.value; - applyFiltersAndRender(); -} - -function toggleTagFilter(tagId) { - if (state.activeTagFilters.has(tagId)) { - state.activeTagFilters.delete(tagId); - } else { - state.activeTagFilters.add(tagId); - } - applyFiltersAndRender(); -} - -function clearFilters() { - state.searchQuery = ''; - state.activeTagFilters.clear(); - applyFiltersAndRender(); -} - -async function applyFiltersAndRender() { - const filtering = isFilterActive(); - if (!filtering) { - const currentDoc = state.documents.find((doc) => doc.id === state.selectedDocumentId); - if (!currentDoc) { - state.selectedDocumentId = state.documents.length ? state.documents[0].id : null; - } - state.searchResults = null; - renderApp(); - return; - } - setLoading(true); - try { - const params = {}; - const trimmedQuery = state.searchQuery.trim(); - if (trimmedQuery.length) { - params.query = trimmedQuery; - } - if (state.activeTagFilters.size > 0) { - params.tags = Array.from(state.activeTagFilters).join(','); - } - const folderPath = state.selectedFolder === 'root' ? 'root' : state.selectedFolder; - const { data } = await api.get(`/folders/${folderPath}/documents`, { - params, - }); - state.searchResults = data || []; - if (state.searchResults.length) { - const found = state.searchResults.find((doc) => doc.id === state.selectedDocumentId); - if (!found) { - state.selectedDocumentId = state.searchResults[0].id; - } - } else if (isFilterActive()) { - state.selectedDocumentId = null; - } - } catch (error) { - console.error(error); - setStatus('Search failed. Please try again.', 'error'); - state.searchResults = null; - } finally { - setLoading(false); - renderApp(); - } -} - -function renderStatusBanner(container) { - if (!state.status) return; - const banner = document.createElement('div'); - banner.className = `status-banner ${state.status.variant}`; - banner.textContent = state.status.message; - container.appendChild(banner); -} - -function renderLogin(app) { - app.innerHTML = ''; - const header = document.createElement('header'); - header.className = 'global-header'; - header.innerHTML = ` -
-

Paperless-NEO

-

Authenticate to start managing documents.

-
- `; - app.appendChild(header); - - const section = document.createElement('section'); - section.className = 'sidebar'; - section.innerHTML = ` -

Sign in

-
- - - - - -
- `; - section - .querySelector('#login-form') - .addEventListener('submit', handleLogin); - renderStatusBanner(section); - app.appendChild(section); -} - -function renderCreateFolderForm() { - const form = document.createElement('form'); - form.className = 'inline'; - form.style.marginBottom = '1rem'; - form.innerHTML = ` - - - `; - form.addEventListener('submit', async (event) => { - event.preventDefault(); - if (!state.token) { - setStatus('Log in to create folders.', 'error'); - return; - } - const input = form.querySelector('input[name="folder-name"]'); - const name = input.value.trim(); - if (!name) { - setStatus('Folder name cannot be empty.', 'error'); - return; - } - const payload = { - name, - parent_id: state.selectedFolder === 'root' ? null : state.selectedFolder, - }; - try { - setLoading(true); - await api.post('/folders', payload); - input.value = ''; - setStatus('Folder created.', 'success'); - await Promise.all([ - ensureFolderData(state.selectedFolder, { force: true }), - selectFolder(state.selectedFolder), - ]); - } catch (error) { - console.error(error); - const message = error.response?.data?.error || 'Failed to create folder.'; - setStatus(message, 'error'); - } finally { - setLoading(false); - } - }); - return form; -} - -function renderFolderNode(node, depth = 0) { - const li = document.createElement('li'); - li.className = 'folder-node'; - - const row = document.createElement('div'); - row.className = 'folder-row'; - row.dataset.folderId = node.id; - row.style.paddingLeft = `${depth * 0.75 + 0.4}rem`; - if (state.selectedFolder === node.id) { - row.classList.add('active'); - } - - const toggle = document.createElement('span'); - toggle.className = 'toggle'; - const hasChildren = node.children.length > 0; - toggle.textContent = hasChildren ? (node.expanded ? '▾' : '▸') : '•'; - if (!hasChildren && node.loaded) { - toggle.classList.add('invisible'); - } - toggle.addEventListener('click', async (event) => { - event.stopPropagation(); - if (!node.loaded) { - await ensureFolderData(node.id); - } - node.expanded = !node.expanded; - renderApp(); - }); - - const name = document.createElement('span'); - name.className = 'name'; - name.textContent = node.name; - - row.appendChild(toggle); - row.appendChild(name); - - if (node.id !== 'root') { - const deleteButton = document.createElement('button'); - deleteButton.type = 'button'; - deleteButton.className = 'icon-button danger'; - deleteButton.textContent = 'Delete'; - deleteButton.disabled = state.loading; - deleteButton.addEventListener('click', (event) => { - event.stopPropagation(); - handleFolderDelete(node.id); - }); - row.appendChild(deleteButton); - } - - row.addEventListener('click', () => { - selectFolder(node.id); - }); - row.addEventListener('dragover', (event) => handleFolderDragOver(event, node.id)); - row.addEventListener('dragleave', handleFolderDragLeave); - row.addEventListener('drop', (event) => handleFolderDrop(event, node.id)); - - li.appendChild(row); - - if (node.expanded && node.children.length) { - const childrenList = document.createElement('ul'); - childrenList.className = 'folder-children'; - node.children.forEach((childId) => { - const childNode = state.folderNodes.get(childId); - if (childNode) { - childrenList.appendChild(renderFolderNode(childNode, depth + 1)); - } - }); - li.appendChild(childrenList); - } - - return li; -} - -function renderSidebar() { - const aside = document.createElement('aside'); - aside.className = 'sidebar'; - aside.innerHTML = '

Folders

'; - aside.appendChild(renderCreateFolderForm()); - - const tree = document.createElement('ul'); - tree.className = 'folder-tree'; - const rootNode = state.folderNodes.get('root'); - if (rootNode) { - tree.appendChild(renderFolderNode(rootNode)); - } - aside.appendChild(tree); - return aside; -} - -function renderFilters(container) { - const filterBar = document.createElement('div'); - filterBar.className = 'filter-bar'; - filterBar.innerHTML = ` - -
-
- `; - const searchInput = filterBar.querySelector('input[type="search"]'); - searchInput.addEventListener('input', handleSearchInput); - - const tagContainer = filterBar.querySelector('.tag-filters'); - if (state.tags.length) { - state.tags.forEach((tag) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = `tag-filter${state.activeTagFilters.has(tag.id) ? ' active' : ''}`; - button.textContent = tag.label; - button.addEventListener('click', () => toggleTagFilter(tag.id)); - tagContainer.appendChild(button); - }); - } else { - const hint = document.createElement('span'); - hint.className = 'meta'; - hint.textContent = 'No tags yet'; - tagContainer.appendChild(hint); - } - - const actions = filterBar.querySelector('.filter-actions'); - if (isFilterActive()) { - const clearButton = document.createElement('button'); - clearButton.type = 'button'; - clearButton.className = 'secondary'; - clearButton.textContent = 'Clear'; - clearButton.addEventListener('click', clearFilters); - actions.appendChild(clearButton); - } - - container.appendChild(filterBar); -} - -function renderDocumentsTable() { - const section = document.createElement('section'); - section.className = 'table-panel'; - - const header = document.createElement('header'); - header.innerHTML = ` -

${getCurrentFolderName()}

-
- -
- `; - header - .querySelector('button') - .addEventListener('click', refreshCurrentFolder); - section.appendChild(header); - - renderFilters(section); - - const showingSearchResults = state.searchResults !== null; - const docs = showingSearchResults ? state.searchResults : state.documents; - - if (!showingSearchResults && !state.currentSubfolders.length && docs.length === 0) { - const empty = document.createElement('div'); - empty.className = 'empty-state'; - empty.textContent = 'Drop files anywhere or onto a folder to upload documents.'; - section.appendChild(empty); - return section; - } - - const table = document.createElement('table'); - table.innerHTML = ` - - - Name - Type - Updated - Tags - Actions - - - - `; - const tbody = table.querySelector('tbody'); - - if (!showingSearchResults) { - state.currentSubfolders.forEach((folder) => { - const row = document.createElement('tr'); - row.className = 'folder'; - row.innerHTML = ` - ${folder.name} - Folder - — - — - `; - row.addEventListener('click', () => selectFolder(folder.id)); - row.addEventListener('dragover', (event) => handleFolderDragOver(event, folder.id)); - row.addEventListener('dragleave', handleFolderDragLeave); - row.addEventListener('drop', (event) => handleFolderDrop(event, folder.id)); - - const actionsCell = document.createElement('td'); - actionsCell.className = 'actions'; - const deleteButton = document.createElement('button'); - deleteButton.type = 'button'; - deleteButton.className = 'icon-button danger'; - deleteButton.textContent = 'Delete'; - deleteButton.disabled = state.loading; - deleteButton.addEventListener('click', (event) => { - event.stopPropagation(); - handleFolderDelete(folder.id); - }); - actionsCell.appendChild(deleteButton); - row.appendChild(actionsCell); - tbody.appendChild(row); - }); - } - - docs.forEach((doc) => { - const row = document.createElement('tr'); - row.className = 'document'; - if (state.selectedDocumentId === doc.id) { - row.classList.add('selected'); - } - row.innerHTML = ` - ${doc.original_name} - ${doc.content_type || 'Document'} - ${doc.updated_at ? new Date(doc.updated_at).toLocaleString() : '—'} - ${(doc.tags || []).map((tag) => `${tag.label}`).join('')} - `; - row.draggable = true; - row.addEventListener('dragstart', (event) => handleDocumentDragStart(event, doc.id)); - row.addEventListener('dragend', handleDocumentDragEnd); - row.addEventListener('click', () => selectDocument(doc.id)); - const docActions = document.createElement('td'); - docActions.className = 'actions'; - row.appendChild(docActions); - tbody.appendChild(row); - }); - - section.appendChild(table); - - if (docs.length === 0 && isFilterActive()) { - const emptyFiltered = document.createElement('div'); - emptyFiltered.className = 'empty-state'; - emptyFiltered.textContent = 'No documents match the current filters.'; - section.appendChild(emptyFiltered); - } else if (showingSearchResults) { - const hint = document.createElement('div'); - hint.className = 'search-hint'; - hint.textContent = `Showing ${docs.length} document${docs.length === 1 ? '' : 's'} matching filters in ${getCurrentFolderName()} and subfolders.`; - section.appendChild(hint); - } - - return section; -} - -function renderDetailPanel() { - const aside = document.createElement('aside'); - aside.className = 'detail-panel'; - aside.innerHTML = '

Details

'; - - let doc = state.documents.find((d) => d.id === state.selectedDocumentId); - if (!doc && state.searchResults) { - doc = state.searchResults.find((d) => d.id === state.selectedDocumentId) || null; - } - if (!doc) { - const message = document.createElement('p'); - message.className = 'meta'; - message.textContent = 'Select a document to view metadata, tags and actions.'; - aside.appendChild(message); - return aside; - } - - const detail = state.documentDetails.get(doc.id); - if (!detail) { - const loadingMessage = document.createElement('p'); - loadingMessage.className = 'meta'; - loadingMessage.textContent = 'Loading details…'; - aside.appendChild(loadingMessage); - return aside; - } - - const title = document.createElement('h3'); - title.textContent = doc.original_name; - title.style.margin = '0'; - aside.appendChild(title); - - const version = detail.current_version; - const meta = document.createElement('div'); - meta.className = 'meta'; - meta.innerHTML = ` -
Uploaded: ${doc.uploaded_at ? new Date(doc.uploaded_at).toLocaleString() : '—'}
-
Size: ${version ? `${(version.size_bytes / 1024).toFixed(1)} KB` : '—'}
-
Type: ${doc.content_type || 'Unknown'}
- `; - aside.appendChild(meta); - - const actions = document.createElement('div'); - actions.style.display = 'flex'; - actions.style.gap = '0.5rem'; - actions.style.margin = '1rem 0'; - const downloadBtn = document.createElement('button'); - downloadBtn.textContent = 'Download'; - downloadBtn.addEventListener('click', () => handleDownload(doc.id)); - actions.appendChild(downloadBtn); - aside.appendChild(actions); - - const tagSection = document.createElement('div'); - tagSection.innerHTML = '
Tags
'; - const tagContainer = document.createElement('div'); - tagContainer.className = 'tag-list'; - if (detail.document.tags?.length) { - detail.document.tags.forEach((tag) => { - const pill = document.createElement('span'); - pill.className = 'tag-pill'; - pill.innerHTML = `${tag.label} `; - pill - .querySelector('button') - .addEventListener('click', () => handleTagRemove(doc.id, tag.id)); - tagContainer.appendChild(pill); - }); - } else { - const none = document.createElement('span'); - none.className = 'meta'; - none.textContent = 'No tags yet.'; - tagContainer.appendChild(none); - } - tagSection.appendChild(tagContainer); - - const tagForm = document.createElement('form'); - tagForm.className = 'inline'; - tagForm.innerHTML = ` - - - `; - tagForm.addEventListener('submit', (event) => handleTagAdd(event, detail.document)); - tagSection.appendChild(tagForm); - - const datalist = document.createElement('datalist'); - datalist.id = 'tag-catalog'; - state.tags.forEach((tag) => { - const option = document.createElement('option'); - option.value = tag.label; - datalist.appendChild(option); - }); - tagSection.appendChild(datalist); - - aside.appendChild(tagSection); - - if (detail.document.metadata && Object.keys(detail.document.metadata).length) { - const metaBlock = document.createElement('div'); - metaBlock.innerHTML = '
Metadata
'; - const pre = document.createElement('pre'); - pre.style.background = 'rgba(15, 23, 42, 0.05)'; - pre.style.padding = '0.75rem'; - pre.style.borderRadius = '10px'; - pre.style.overflowX = 'auto'; - pre.textContent = JSON.stringify(detail.document.metadata, null, 2); - metaBlock.appendChild(pre); - aside.appendChild(metaBlock); - } - - return aside; -} - -function renderDashboard(app) { - app.innerHTML = ''; - setupGlobalDragAndDrop(); - - const header = document.createElement('header'); - header.className = 'global-header'; - header.innerHTML = ` -
-

Paperless-NEO

-

Drag files anywhere to upload into "${getCurrentFolderName()}".

-
-
- -
- `; - header - .querySelector('button') - .addEventListener('click', handleLogout); - app.appendChild(header); - - renderStatusBanner(app); - - const layout = document.createElement('div'); - layout.className = 'dashboard-layout'; - layout.appendChild(renderSidebar()); - layout.appendChild(renderDocumentsTable()); - layout.appendChild(renderDetailPanel()); - app.appendChild(layout); -} - -function renderApp() { - const app = document.getElementById('app'); - if (!app) return; - if (!state.token) { - renderLogin(app); - } else { - renderDashboard(app); - } -} - -renderApp(); - -if (state.token) { - initializeAfterLogin(); -} diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx new file mode 100644 index 0000000..840d0fa --- /dev/null +++ b/frontend/src/index.jsx @@ -0,0 +1,1653 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createRoot } from 'react-dom/client'; +import axios from 'axios'; +import './styles.css'; + +const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, ''); + +const api = axios.create({ + baseURL: API_ROOT ? `${API_ROOT}/api` : '/api', + headers: { + 'Content-Type': 'application/json', + }, +}); + +const DEFAULT_FOLDER_NAME = 'All Documents'; + +const hasFiles = (event) => + Array.from(event.dataTransfer?.types || []).includes('Files'); + +const IconChevronRight = ({ className }) => ( + +); + +const IconTrash = ({ className }) => ( + +); + +const IconDownload = ({ className }) => ( + +); + +const createRootNode = () => ({ + id: 'root', + name: DEFAULT_FOLDER_NAME, + parentId: null, + children: [], + expanded: true, + loaded: false, +}); + +const StatusBanner = ({ status }) => { + if (!status) return null; + return
{status.message}
; +}; + +const DropOverlay = ({ active, folderName }) => ( +
+
+ Drop files to upload to {folderName} +
+
+); + +const LoginView = ({ onSubmit, loading, status }) => ( +
+
+

Paperless-NEO

+

Authenticate to manage your documents.

+
+ + + + + +
+ +
+
+); + +const FolderNode = ({ + node, + depth, + isSelected, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onDelete, + disableActions, + renderChildren, +}) => { + const canToggle = node.id === 'root' || node.children.length > 0 || !node.loaded; + const icon = canToggle ? : null; + + const handleToggleClick = (event) => { + event.stopPropagation(); + if (canToggle) { + onToggle(node.id); + } + }; + + return ( +
  • +
    onSelect(node.id)} + onDragOver={(event) => onDragOver(event, node.id)} + onDragLeave={onDragLeave} + onDrop={(event) => onDrop(event, node.id)} + > + + {icon} + + {node.name} + {node.id !== 'root' && ( + + )} +
    + {node.expanded && node.children.length > 0 && ( +
      + {renderChildren(node.children, depth + 1)} +
    + )} +
  • + ); +}; + +const FilterBar = ({ + query, + onQueryChange, + tags, + activeTagIds, + onToggleTag, + onClear, + hasFilters, +}) => ( +
    + onQueryChange(event.target.value)} + /> +
    + {tags.length ? ( + tags.map((tag) => ( + + )) + ) : ( + No tags yet + )} +
    +
    + {hasFilters && ( + + )} +
    +
    +); + +const DocumentsTable = ({ + currentFolderName, + loading, + onRefresh, + subfolders, + documents, + searchResults, + isFilterActive, + onFolderSelect, + onFolderDrop, + onFolderDragOver, + onFolderDragLeave, + onFolderDelete, + onDocumentSelect, + selectedDocumentId, + onDocumentDragStart, + onDocumentDragEnd, + onDownload, + filterBar, +}) => { + const showingSearchResults = searchResults !== null; + const rows = showingSearchResults ? searchResults : documents; + + return ( +
    +
    +
    +

    {currentFolderName}

    + {showingSearchResults && ( +
    Search results
    + )} +
    +
    + +
    +
    +
    +
    {filterBar}
    +
    + {!showingSearchResults && !subfolders.length && rows.length === 0 ? ( +
    + Drop files anywhere or onto a folder to upload documents. +
    + ) : ( + + + + + + + + + + + + + {!showingSearchResults && + subfolders.map((folder) => ( + onFolderSelect(folder.id)} + onDragOver={(event) => onFolderDragOver(event, folder.id)} + onDragLeave={onFolderDragLeave} + onDrop={(event) => onFolderDrop(event, folder.id)} + > + + + + + + + + ))} + {rows.map((doc) => ( + onDocumentSelect(doc.id)} + draggable + onDragStart={(event) => onDocumentDragStart(event, doc.id)} + onDragEnd={onDocumentDragEnd} + > + + + + + + + + ))} + +
    PreviewNameTypeUpdatedTagsActions
    +
    DIR
    +
    {folder.name}Folder + +
    + {doc.thumbnail ? ( + {`Thumbnail + ) : ( +
    DOC
    + )} +
    {doc.original_name}{doc.content_type || 'Document'}{doc.updated_at ? new Date(doc.updated_at).toLocaleString() : '—'} + {(doc.tags || []).map((tag) => ( + + {tag.label} + + ))} + + +
    + )} +
    + {rows.length === 0 && isFilterActive && ( +
    No documents match the current filters.
    + )} + {showingSearchResults && rows.length > 0 && ( +
    + Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. +
    + )} +
    +
    + ); +}; + +const DetailPanel = ({ + document, + detail, + tags, + onDownload, + onTagAdd, + onTagRemove, + onRegenerateThumbnails, + loading, +}) => ( + +); + +const Sidebar = ({ + folderNodes, + onToggle, + onSelect, + onDrop, + onDragOver, + onDragLeave, + onCreateFolder, + onDeleteFolder, + disableActions, + selectedFolder, +}) => { + const renderNodes = useCallback( + (ids, depth) => + ids.map((id) => { + const node = folderNodes.get(id); + if (!node) return null; + return ( + onToggle(id)} + onSelect={onSelect} + onDrop={onDrop} + onDragOver={onDragOver} + onDragLeave={onDragLeave} + onDelete={onDeleteFolder} + disableActions={disableActions} + renderChildren={renderNodes} + /> + ); + }), + [folderNodes, selectedFolder, onToggle, onSelect, onDrop, onDragOver, onDragLeave, onDeleteFolder, disableActions], + ); + + const rootNode = folderNodes.get('root'); + + return ( + + ); +}; + +function App() { + const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || ''); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [folderNodes, setFolderNodes] = useState(() => { + const rootNode = createRootNode(); + return new Map([[rootNode.id, rootNode]]); + }); + const [folderContents, setFolderContents] = useState(() => new Map()); + const [selectedFolder, setSelectedFolder] = useState('root'); + const [currentFolder, setCurrentFolder] = useState(null); + const [currentSubfolders, setCurrentSubfolders] = useState([]); + const [documents, setDocuments] = useState([]); + const [selectedDocumentId, setSelectedDocumentId] = useState(null); + const [documentDetails, setDocumentDetails] = useState(() => new Map()); + const documentDetailsRef = useRef(documentDetails); + const [searchResults, setSearchResults] = useState(null); + const [tags, setTags] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [activeTagFilters, setActiveTagFilters] = useState([]); + const [draggedDocumentId, setDraggedDocumentId] = useState(null); + const [dropOverlayState, setDropOverlayState] = useState({ + active: false, + folderName: DEFAULT_FOLDER_NAME, + }); + + const initializedRef = useRef(false); + const dragCounterRef = useRef(0); + + useEffect(() => { + documentDetailsRef.current = documentDetails; + }, [documentDetails]); + + useEffect(() => { + if (token) { + api.defaults.headers.common.Authorization = `Bearer ${token}`; + window.localStorage.setItem('paperless_token', token); + } else { + delete api.defaults.headers.common.Authorization; + window.localStorage.removeItem('paperless_token'); + } + }, [token]); + + const setStatusMessage = useCallback((message, variant = 'info') => { + setStatus(message ? { message, variant } : null); + }, []); + + const currentFolderName = useMemo(() => { + if (selectedFolder === 'root' || !currentFolder) return DEFAULT_FOLDER_NAME; + return currentFolder.name; + }, [selectedFolder, currentFolder]); + + const isFilterActive = useMemo( + () => searchQuery.trim().length > 0 || activeTagFilters.length > 0, + [searchQuery, activeTagFilters], + ); + + const applySelectedFolder = useCallback( + (folderId, contents) => { + const subfolders = contents?.subfolders ?? []; + const docs = contents?.documents ?? []; + const folderInfo = contents?.folder ?? null; + + setCurrentSubfolders(subfolders); + setDocuments(docs); + setCurrentFolder(folderInfo); + + let nextSelectedId = null; + setSelectedDocumentId((previous) => { + if (previous && docs.some((doc) => doc.id === previous)) { + nextSelectedId = previous; + return previous; + } + nextSelectedId = docs.length ? docs[0].id : null; + return nextSelectedId; + }); + + return nextSelectedId; + }, + [], + ); + + const ensureFolderData = useCallback( + async (folderId, { force = false } = {}) => { + if (!force && folderContents.has(folderId)) { + return folderContents.get(folderId); + } + + const path = folderId === 'root' ? 'root' : folderId; + const { data } = await api.get(`/folders/${path}/contents`); + + setFolderNodes((prev) => { + const next = new Map(prev); + const existingNode = next.get(folderId) || { + id: folderId, + name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || 'Folder', + parentId: data.folder?.parent_id || 'root', + children: [], + expanded: folderId === 'root', + loaded: false, + }; + + const childIds = (data.subfolders || []).map((child) => child.id); + next.set(folderId, { + ...existingNode, + name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name, + parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root', + children: childIds, + loaded: true, + }); + + (data.subfolders || []).forEach((child) => { + const childNode = next.get(child.id); + next.set(child.id, { + id: child.id, + name: child.name, + parentId: child.parent_id ?? 'root', + children: childNode?.children ?? [], + expanded: childNode?.expanded ?? false, + loaded: childNode?.loaded ?? false, + }); + }); + + return next; + }); + + setFolderContents((prev) => { + const next = new Map(prev); + next.set(folderId, data); + return next; + }); + + return data; + }, + [folderContents], + ); + + const ensureDocumentDetail = useCallback( + async (documentId, { force = false } = {}) => { + if (!documentId) return null; + const cached = documentDetailsRef.current.get(documentId); + if (!force && cached) { + return cached; + } + + const { data } = await api.get(`/documents/${documentId}`); + + setDocumentDetails((prev) => { + const next = new Map(prev); + next.set(documentId, data); + documentDetailsRef.current = next; + return next; + }); + + setDocuments((prev) => + prev.map((doc) => (doc.id === documentId ? data.document : doc)), + ); + + setSearchResults((prev) => + prev ? prev.map((doc) => (doc.id === documentId ? data.document : doc)) : null, + ); + + return data; + }, + [setDocumentDetails, setDocuments, setSearchResults], + ); + + const refreshTags = useCallback(async () => { + try { + const { data } = await api.get('/tags'); + setTags(data || []); + } catch (error) { + console.error(error); + setStatusMessage('Unable to load tags.', 'error'); + } + }, [setStatusMessage]); + + const selectFolder = useCallback( + async (folderId) => { + setSelectedFolder(folderId); + setLoading(true); + try { + const contents = await ensureFolderData(folderId, { force: true }); + const nextSelectedId = applySelectedFolder(folderId, contents); + setSearchResults(null); + if (nextSelectedId) { + await ensureDocumentDetail(nextSelectedId, { force: true }); + } + } catch (error) { + console.error(error); + setStatusMessage('Failed to load folder contents.', 'error'); + } finally { + setLoading(false); + } + }, + [ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage], + ); + + const initializeAfterLogin = useCallback(async () => { + setLoading(true); + try { + await refreshTags(); + const contents = await ensureFolderData('root', { force: true }); + const nextSelectedId = applySelectedFolder('root', contents); + if (nextSelectedId) { + await ensureDocumentDetail(nextSelectedId, { force: true }); + } + } catch (error) { + console.error(error); + setStatusMessage('Failed to initialize data.', 'error'); + } finally { + setLoading(false); + } + }, [refreshTags, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]); + + const refreshCurrentFolder = useCallback(async () => { + setLoading(true); + try { + const contents = await ensureFolderData(selectedFolder, { force: true }); + const nextSelectedId = applySelectedFolder(selectedFolder, contents); + if (nextSelectedId) { + await ensureDocumentDetail(nextSelectedId, { force: true }); + } + } catch (error) { + console.error(error); + setStatusMessage('Failed to refresh folder.', 'error'); + } finally { + setLoading(false); + } + }, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]); + + useEffect(() => { + if (!token || initializedRef.current) return; + initializedRef.current = true; + initializeAfterLogin(); + }, [token, initializeAfterLogin]); + + const uploadFile = useCallback( + async (file, targetFolderId) => { + const formData = new FormData(); + formData.append('file', file); + if (targetFolderId && targetFolderId !== 'root') { + formData.append('folder_id', targetFolderId); + } + try { + const response = await api.post('/documents', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + if (response.status === 200) { + setStatusMessage(`${file.name} already exists; reused existing document.`, 'info'); + } else { + setStatusMessage(`Uploaded ${file.name}`, 'success'); + } + } catch (error) { + console.error(error); + const message = error.response?.data?.error || `Failed to upload ${file.name}.`; + setStatusMessage(message, 'error'); + throw error; + } + }, + [setStatusMessage], + ); + + const folderPathCacheRef = useRef(new Map()); + + const ensureFolderPathOnServer = useCallback( + async (baseFolderId, segments) => { + const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean); + if (trimmedSegments.length === 0) { + return baseFolderId ?? null; + } + + const cacheKey = `${baseFolderId ?? 'ROOT'}:${trimmedSegments.join('/')}`; + const cache = folderPathCacheRef.current; + if (cache.has(cacheKey)) { + return cache.get(cacheKey); + } + + const payload = { + parent_id: baseFolderId && baseFolderId !== 'root' ? baseFolderId : null, + segments: trimmedSegments, + }; + + const { data } = await api.post('/folders/path', payload); + const folderId = data.folder.id; + cache.set(cacheKey, folderId); + return folderId; + }, + [], + ); + + const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { + const results = []; + + if (!dataTransfer) { + return results; + } + + const walkDirectoryHandle = async (handle, ancestors) => { + const currentPath = handle.name ? [...ancestors, handle.name] : [...ancestors]; + for await (const childHandle of handle.values()) { + if (childHandle.kind === 'file') { + const file = await childHandle.getFile(); + results.push({ file, segments: currentPath }); + } else if (childHandle.kind === 'directory') { + await walkDirectoryHandle(childHandle, currentPath); + } + } + }; + + const walkEntry = async (entry, ancestors) => { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + results.push({ file, segments: ancestors }); + return; + } + + if (entry.isDirectory) { + const childAncestors = entry.name ? [...ancestors, entry.name] : [...ancestors]; + const reader = entry.createReader(); + const readEntries = () => + new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + + let entries; + do { + // eslint-disable-next-line no-await-in-loop + entries = await readEntries(); + for (const child of entries) { + // eslint-disable-next-line no-await-in-loop + await walkEntry(child, childAncestors); + } + } while (entries.length); + } + }; + + const items = dataTransfer.items ? Array.from(dataTransfer.items).filter((item) => item.kind === 'file') : []; + + if (items.length) { + for (const item of items) { + // eslint-disable-next-line no-await-in-loop + if (item.getAsFileSystemHandle) { + // eslint-disable-next-line no-await-in-loop + const handle = await item.getAsFileSystemHandle(); + if (!handle) continue; + if (handle.kind === 'file') { + // eslint-disable-next-line no-await-in-loop + const file = await handle.getFile(); + results.push({ file, segments: [] }); + } else if (handle.kind === 'directory') { + // eslint-disable-next-line no-await-in-loop + await walkDirectoryHandle(handle, []); + } + continue; + } + + const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null; + if (entry) { + // eslint-disable-next-line no-await-in-loop + await walkEntry(entry, []); + continue; + } + + const file = item.getAsFile ? item.getAsFile() : null; + if (file) { + const segments = file.webkitRelativePath + ? file.webkitRelativePath.split('/').slice(0, -1).filter(Boolean) + : []; + results.push({ file, segments }); + continue; + } + } + } + + if (!results.length && dataTransfer.files) { + results.push( + ...Array.from(dataTransfer.files).map((file) => ({ + file, + segments: file.webkitRelativePath + ? file.webkitRelativePath.split('/').slice(0, -1).filter(Boolean) + : [], + })), + ); + } + + return results; + }, []); + + const handleFileDrop = useCallback( + async (dataTransfer, targetFolderId) => { + if (!token) { + setStatusMessage('Please log in before uploading.', 'error'); + return; + } + setLoading(true); + try { + folderPathCacheRef.current.clear(); + const extracted = await extractFilesFromDataTransfer(dataTransfer); + const baseFolderId = targetFolderId && targetFolderId !== 'root' ? targetFolderId : null; + + if (!extracted.length && dataTransfer?.files) { + for (const file of Array.from(dataTransfer.files)) { + // eslint-disable-next-line no-await-in-loop + await uploadFile(file, targetFolderId ?? 'root'); + } + } else { + for (const { file, segments } of extracted) { + // eslint-disable-next-line no-await-in-loop + const destinationId = segments.length + ? await ensureFolderPathOnServer(baseFolderId, segments) + : baseFolderId; + + const uploadTarget = destinationId + ?? (targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root'); + + // eslint-disable-next-line no-await-in-loop + await uploadFile(file, uploadTarget); + } + } + await refreshCurrentFolder(); + if (targetFolderId && targetFolderId !== 'root' && targetFolderId !== selectedFolder) { + await ensureFolderData(targetFolderId, { force: true }); + } + } catch (error) { + // errors surfaced through setStatusMessage + } finally { + setLoading(false); + } + }, + [ + token, + extractFilesFromDataTransfer, + ensureFolderPathOnServer, + uploadFile, + refreshCurrentFolder, + selectedFolder, + ensureFolderData, + setStatusMessage, + ], + ); + + const moveDocumentToFolder = useCallback( + async (documentId, targetFolderId) => { + const target = targetFolderId === 'root' ? null : targetFolderId; + try { + await api.patch(`/documents/${documentId}/folder`, { folder_id: target }); + setStatusMessage('Document moved.', 'success'); + setDocumentDetails((prev) => { + const next = new Map(prev); + next.delete(documentId); + return next; + }); + await refreshCurrentFolder(); + if (targetFolderId) { + await ensureFolderData(targetFolderId, { force: true }); + } + } catch (error) { + console.error(error); + setStatusMessage('Failed to move document.', 'error'); + } + }, + [ensureFolderData, refreshCurrentFolder, setStatusMessage], + ); + +const handleDownload = useCallback( + async (documentId) => { + try { + setLoading(true); + const { data } = await api.get(`/documents/${documentId}/download`); + window.open(data.url, '_blank'); + setStatusMessage('Download link opened in a new tab.', 'success'); + } catch (error) { + console.error(error); + setStatusMessage('Unable to fetch download link.', 'error'); + } finally { + setLoading(false); + } + }, + [setStatusMessage], +); + + const handleThumbnailRegeneration = useCallback( + async (documentId) => { + if (!token) { + setStatusMessage('Log in to manage assets.', 'error'); + return; + } + setLoading(true); + try { + await api.post(`/documents/${documentId}/assets`, null, { + params: { force: true }, + }); + setStatusMessage('Thumbnail regeneration requested.', 'info'); + await ensureDocumentDetail(documentId, { force: true }); + } catch (error) { + console.error(error); + const message = + error.response?.data?.error || 'Failed to request thumbnail generation.'; + setStatusMessage(message, 'error'); + } finally { + setLoading(false); + } + }, + [token, ensureDocumentDetail, setStatusMessage], + ); + + const handleTagRemove = useCallback( + async (documentId, tagId) => { + try { + await api.delete(`/documents/${documentId}/tags/${tagId}`); + setStatusMessage('Tag removed.', 'success'); + await Promise.all([ + refreshCurrentFolder(), + refreshTags(), + ensureDocumentDetail(documentId, { force: true }), + ]); + } catch (error) { + console.error(error); + setStatusMessage('Failed to remove tag.', 'error'); + } + }, + [ensureDocumentDetail, refreshCurrentFolder, refreshTags, setStatusMessage], + ); + + const handleTagAdd = useCallback( + async (document, label, input) => { + let tag = tags.find((item) => item.label.toLowerCase() === label.toLowerCase()) || null; + try { + if (!tag) { + const { data } = await api.post('/tags', { label, color: null }); + tag = data; + await refreshTags(); + } + await api.post(`/documents/${document.id}/tags`, { tag_ids: [tag.id] }); + setStatusMessage('Tag assigned.', 'success'); + input.value = ''; + await Promise.all([ + refreshCurrentFolder(), + ensureDocumentDetail(document.id, { force: true }), + ]); + } catch (error) { + console.error(error); + setStatusMessage('Failed to assign tag.', 'error'); + } + }, + [tags, refreshTags, refreshCurrentFolder, ensureDocumentDetail, setStatusMessage], + ); + + const handleFolderDelete = useCallback( + async (folderId) => { + if (!token) { + setStatusMessage('Log in to manage folders.', 'error'); + return; + } + if (folderId === 'root') { + setStatusMessage('The root folder cannot be removed.', 'error'); + return; + } + setLoading(true); + try { + const contents = await ensureFolderData(folderId, { force: true }); + const hasChildren = (contents.subfolders || []).length > 0; + const hasDocs = (contents.documents || []).length > 0; + if (hasChildren || hasDocs) { + setStatusMessage('Folder must be empty before it can be deleted.', 'error'); + return; + } + await api.delete(`/folders/${folderId}`); + setFolderNodes((prev) => { + const next = new Map(prev); + const node = next.get(folderId); + next.delete(folderId); + if (node) { + const parentId = node.parentId || 'root'; + const parentNode = next.get(parentId); + if (parentNode) { + next.set(parentId, { + ...parentNode, + children: parentNode.children.filter((id) => id !== folderId), + }); + } + } + return next; + }); + setFolderContents((prev) => { + const next = new Map(prev); + next.delete(folderId); + return next; + }); + if (selectedFolder === folderId) { + const node = folderNodes.get(folderId); + const parentId = node?.parentId || 'root'; + setSelectedFolder(parentId); + const parentContents = await ensureFolderData(parentId, { force: true }); + const nextSelectedId = applySelectedFolder(parentId, parentContents); + if (nextSelectedId) { + await ensureDocumentDetail(nextSelectedId, { force: true }); + } + } else if (selectedFolder !== 'root') { + await ensureFolderData(selectedFolder, { force: true }); + } + setStatusMessage('Folder deleted.', 'success'); + } catch (error) { + console.error(error); + const message = error.response?.data?.error || 'Failed to delete folder.'; + setStatusMessage(message, 'error'); + } finally { + setLoading(false); + } + }, + [token, ensureFolderData, selectedFolder, folderNodes, applySelectedFolder, ensureDocumentDetail, setStatusMessage], + ); + + const handleFolderCreate = useCallback( + async (name, onSuccess) => { + if (!token) { + setStatusMessage('Log in to create folders.', 'error'); + return; + } + if (!name.trim()) { + setStatusMessage('Folder name cannot be empty.', 'error'); + return; + } + const payload = { + name: name.trim(), + parent_id: selectedFolder === 'root' ? null : selectedFolder, + }; + setLoading(true); + try { + const { data } = await api.post('/folders', payload); + onSuccess(); + setStatusMessage('Folder created.', 'success'); + setFolderNodes((prev) => { + const next = new Map(prev); + const parentId = payload.parent_id || 'root'; + const parentNode = next.get(parentId); + if (parentNode) { + next.set(parentId, { + ...parentNode, + children: parentNode.children.concat([data.folder.id]), + loaded: true, + }); + } + next.set(data.folder.id, { + id: data.folder.id, + name: data.folder.name, + parentId: parentId, + children: [], + expanded: false, + loaded: false, + }); + return next; + }); + await ensureFolderData(selectedFolder, { force: true }); + } catch (error) { + console.error(error); + const message = error.response?.data?.error || 'Failed to create folder.'; + setStatusMessage(message, 'error'); + } finally { + setLoading(false); + } + }, + [token, selectedFolder, ensureFolderData, setStatusMessage], + ); + + useEffect(() => { + if (!token) return undefined; + + if (!isFilterActive) { + setSearchResults(null); + return undefined; + } + + let cancelled = false; + let started = false; + + const debounce = setTimeout(async () => { + started = true; + setLoading(true); + try { + const params = {}; + const trimmedQuery = searchQuery.trim(); + if (trimmedQuery.length) { + params.query = trimmedQuery; + } + if (activeTagFilters.length) { + params.tags = activeTagFilters.join(','); + } + const folderIdentifier = selectedFolder === 'root' ? 'root' : selectedFolder; + const { data } = await api.get(`/folders/${folderIdentifier}/documents`, { + params, + }); + if (cancelled) return; + + const results = data || []; + setSearchResults(results); + + if (!results.length) { + setSelectedDocumentId(null); + return; + } + + const targetId = results.some((doc) => doc.id === selectedDocumentId) + ? selectedDocumentId + : results[0].id; + setSelectedDocumentId(targetId); + + if (!cancelled && targetId) { + const cached = documentDetailsRef.current.get(targetId); + await ensureDocumentDetail(targetId, { force: !cached }); + } + } catch (error) { + if (cancelled) return; + console.error(error); + setStatusMessage('Search failed. Please try again.', 'error'); + setSearchResults(null); + } finally { + if (!cancelled && started) { + setLoading(false); + } + } + }, 150); + + return () => { + cancelled = true; + clearTimeout(debounce); + if (started) { + setLoading(false); + } + }; + }, [ + token, + isFilterActive, + searchQuery, + activeTagFilters, + selectedFolder, + selectedDocumentId, + ensureDocumentDetail, + setStatusMessage, + ]); + + useEffect(() => { + if (!token) { + setDropOverlayState((prev) => ({ ...prev, active: false })); + dragCounterRef.current = 0; + return undefined; + } + + const handleDragEnter = (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + dragCounterRef.current += 1; + setDropOverlayState({ active: true, folderName: currentFolderName }); + }; + + const handleDragOver = (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragLeave = (event) => { + if (!hasFiles(event)) return; + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) { + setDropOverlayState((prev) => ({ ...prev, active: false })); + } + }; + + const handleDrop = async (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + dragCounterRef.current = 0; + setDropOverlayState((prev) => ({ ...prev, active: false })); + await handleFileDrop(event.dataTransfer, selectedFolder); + }; + + window.addEventListener('dragenter', handleDragEnter); + window.addEventListener('dragover', handleDragOver); + window.addEventListener('dragleave', handleDragLeave); + window.addEventListener('drop', handleDrop); + + return () => { + window.removeEventListener('dragenter', handleDragEnter); + window.removeEventListener('dragover', handleDragOver); + window.removeEventListener('dragleave', handleDragLeave); + window.removeEventListener('drop', handleDrop); + dragCounterRef.current = 0; + setDropOverlayState((prev) => ({ ...prev, active: false })); + }; + }, [token, handleFileDrop, currentFolderName, selectedFolder]); + + const handleLogin = useCallback( + async (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const payload = { + username: form.get('username')?.toString().trim(), + password: form.get('password')?.toString() || '', + }; + if (!payload.username || !payload.password) { + setStatusMessage('Username and password are required.', 'error'); + return; + } + try { + setLoading(true); + const { data } = await api.post('/auth/login', payload); + setToken(data.access_token); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + console.error(error); + setStatusMessage('Login failed. Check credentials.', 'error'); + } finally { + setLoading(false); + } + }, + [setStatusMessage], + ); + + const handleLogout = useCallback(() => { + setToken(''); + setStatusMessage('Logged out.', 'info'); + setFolderNodes(new Map([[createRootNode().id, createRootNode()]])); + setFolderContents(new Map()); + setSelectedFolder('root'); + setCurrentFolder(null); + setCurrentSubfolders([]); + setDocuments([]); + setSelectedDocumentId(null); + setDocumentDetails(new Map()); + setSearchResults(null); + setTags([]); + setSearchQuery(''); + setActiveTagFilters([]); + initializedRef.current = false; + }, [setStatusMessage]); + + const handleBulkReanalyze = useCallback(async () => { + try { + setLoading(true); + const { data } = await api.post('/documents/reanalyze'); + const total = data?.queued ?? 0; + const suffix = total === 1 ? '' : 's'; + setStatusMessage( + `Queued re-analysis for ${total} document${suffix}.`, + 'success', + ); + } catch (error) { + console.error(error); + const message = + error.response?.data?.error || 'Failed to queue document re-analysis.'; + setStatusMessage(message, 'error'); + } finally { + setLoading(false); + } + }, [setStatusMessage]); + + + const folderClickHandlers = { + onToggle: async (folderId) => { + const node = folderNodes.get(folderId); + if (node && !node.loaded) { + try { + await ensureFolderData(folderId); + } catch (error) { + console.error(error); + setStatusMessage('Failed to load folder.', 'error'); + } + } + setFolderNodes((prev) => { + const next = new Map(prev); + const current = next.get(folderId); + if (!current) return prev; + next.set(folderId, { ...current, expanded: !current.expanded }); + return next; + }); + }, + onSelect: selectFolder, + onDrop: async (event, folderId) => { + event.preventDefault(); + event.currentTarget.classList.remove('is-drop-target'); + try { + await handleFileDrop(event.dataTransfer, folderId); + } finally { + // no-op; errors handled inside handleFileDrop + } + const documentId = event.dataTransfer.getData('application/x-paperless-doc') || draggedDocumentId; + if (!documentId || folderId === selectedFolder) return; + moveDocumentToFolder(documentId, folderId); + }, + onDragOver: (event) => { + if (hasFiles(event) || draggedDocumentId) { + event.preventDefault(); + event.currentTarget.classList.add('is-drop-target'); + } + }, + onDragLeave: (event) => { + event.currentTarget.classList.remove('is-drop-target'); + }, + }; + + const filterBar = ( + + setActiveTagFilters((prev) => + prev.includes(tagId) + ? prev.filter((id) => id !== tagId) + : prev.concat([tagId]), + ) + } + onClear={() => { + setSearchQuery(''); + setActiveTagFilters([]); + }} + hasFilters={isFilterActive} + /> + ); + + const selectedDocument = useMemo(() => { + const list = searchResults ?? documents; + return list.find((doc) => doc.id === selectedDocumentId) || null; + }, [searchResults, documents, selectedDocumentId]); + + const selectedDetail = selectedDocument + ? documentDetails.get(selectedDocument.id) + : null; + + return ( +
    + {token ? ( + <> + +
    +
    +

    Paperless-NEO

    + + Drag files anywhere to upload into "{currentFolderName}". + +
    +
    + + +
    +
    + {status && ( +
    + +
    + )} +
    + + { + setSelectedDocumentId(docId); + ensureDocumentDetail(docId); + }} + selectedDocumentId={selectedDocumentId} + onDocumentDragStart={(event, documentId) => { + setDraggedDocumentId(documentId); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('application/x-paperless-doc', documentId); + event.currentTarget.classList.add('dragging'); + }} + onDocumentDragEnd={(event) => { + setDraggedDocumentId(null); + event.currentTarget.classList.remove('dragging'); + }} + onDownload={handleDownload} + filterBar={filterBar} + /> + +
    + + ) : ( + + )} +
    + ); +} + +const container = document.getElementById('app'); +const root = createRoot(container); +root.render(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9647f7d..1e65c29 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,122 +1,224 @@ :root { - color-scheme: light dark; - --bg: #f3f4f6; - --surface: rgba(255, 255, 255, 0.9); - --surface-strong: rgba(255, 255, 255, 0.95); - --fg: #1f2937; - --muted: #6b7280; - --border: #d1d5db; - --accent: #2563eb; - --accent-soft: rgba(37, 99, 235, 0.12); - --accent-strong: rgba(37, 99, 235, 0.2); - --danger: #dc2626; - --success: #10b981; + color-scheme: light; + --bg: #f4f5f7; + --surface: #ffffff; + --surface-subtle: #f8f9fb; + --fg: #1b1f24; + --muted: #5f6673; + --border: #d0d5dd; + --accent: #2b5cff; + --accent-soft: rgba(43, 92, 255, 0.12); + --danger: #d92c20; + --success: #12805a; font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 15px; +} + +html, +body { + height: 100%; } body { margin: 0; background: var(--bg); color: var(--fg); + overflow: hidden; } #app { - min-height: 100vh; - display: flex; - flex-direction: column; - padding: 2rem clamp(1rem, 3vw, 3rem); - box-sizing: border-box; -} - -header.global-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1.5rem; - gap: 1rem; -} - -header.global-header h1 { - margin: 0; - font-size: 1.9rem; + height: 100%; + background: var(--bg); } button { font: inherit; - border-radius: 8px; - border: none; - padding: 0.55rem 1.1rem; + border-radius: 2px; + border: 1px solid transparent; + padding: 0.35rem 0.85rem; background: var(--accent); - color: white; + color: #fff; cursor: pointer; - transition: transform 0.1s ease, box-shadow 0.15s ease; + font-weight: 500; + transition: background 0.15s ease, border-color 0.15s ease; } button[disabled] { - opacity: 0.6; + opacity: 0.55; cursor: not-allowed; - transform: none; - box-shadow: none; } button:hover:not([disabled]) { - transform: translateY(-1px); - box-shadow: 0 10px 20px var(--accent-strong); + background: #2047d9; } button.secondary { background: transparent; - color: var(--accent); - border: 1px solid var(--accent); + color: var(--fg); + border-color: var(--border); +} + +button.secondary:hover:not([disabled]) { + background: var(--surface-subtle); } button.danger { - background: var(--danger); - color: #fff; - box-shadow: none; + background: transparent; + color: var(--danger); + border-color: rgba(217, 44, 32, 0.45); } button.danger:hover:not([disabled]) { - box-shadow: 0 8px 16px rgba(220, 38, 38, 0.2); + border-color: var(--danger); + background: rgba(217, 44, 32, 0.08); } button.icon-button { - padding: 0.35rem 0.65rem; + padding: 0.25rem 0.55rem; min-width: auto; } -button.icon-button:hover:not([disabled]) { - transform: none; +button.icon-button.ghost { + border: none; + background: transparent; + color: var(--muted); + padding: 0.2rem; } -.dashboard-layout { - display: grid; - grid-template-columns: 260px minmax(0, 1fr) 320px; - gap: 1.5rem; - align-items: start; - flex: 1; +button.icon-button.ghost:hover:not([disabled]) { + color: var(--danger); + background: transparent; } -.sidebar, -.table-panel, -.detail-panel { +.with-icon { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.icon-inline { + width: 1rem; + height: 1rem; +} + +.icon { + width: 1rem; + height: 1rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.icon path { + stroke: currentColor; + stroke-width: 1.6; + fill: none; +} + +.app-shell { + height: 100%; + display: flex; + flex-direction: column; + color: var(--fg); + background: var(--bg); +} + +.app-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1.5rem; background: var(--surface); - border-radius: 16px; - padding: 1.25rem; - box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12); - backdrop-filter: blur(14px); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; } -.sidebar { - position: relative; - min-height: calc(100vh - 6rem); +.app-bar__meta { + display: flex; + flex-direction: column; + gap: 0.2rem; } -.sidebar h2, -.table-panel h2, -.detail-panel h2 { - margin: 0 0 1rem; +.app-bar h1 { + margin: 0; font-size: 1.1rem; + font-weight: 600; +} + +.app-bar__hint { + color: var(--muted); + font-size: 0.9rem; +} + +.app-bar__actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.status-container { + padding: 0.4rem 1.5rem 0; + flex-shrink: 0; +} + +.app-main { + flex: 1; + display: grid; + grid-template-columns: 220px minmax(0, 1fr) 320px; + gap: 1.5rem; + padding: 0.75rem 1.5rem 1.25rem; + min-height: 0; + overflow: hidden; +} + +.column { + background: transparent; + border: none; + display: flex; + flex-direction: column; + min-height: 0; +} + +.column + .column { + border-left: 1px solid var(--border); + padding-left: 0.6rem; +} + +.column-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0 0 0.5rem; + border-bottom: 1px solid var(--border); +} + +.column-header h2 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.column-subtitle { + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--muted); +} + + +.column-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.32rem; + padding: 0.35rem 0 0.55rem; + min-height: 0; +} + +.column-body.scrollable { + overflow-y: auto; } .folder-tree { @@ -132,11 +234,13 @@ button.icon-button:hover:not([disabled]) { .folder-row { display: flex; align-items: center; - gap: 0.55rem; - padding: 0.4rem 0.55rem; - border-radius: 8px; + gap: 0.22rem; + padding: 0.16rem 0.26rem; + border-radius: 2px; cursor: pointer; color: var(--fg); + transition: background 0.12s ease, color 0.12s ease; + font-size: 0.78rem; } .folder-row span.name { @@ -147,28 +251,54 @@ button.icon-button:hover:not([disabled]) { } .folder-row:hover { - background: var(--accent-soft); + background: var(--surface-subtle); } .folder-row.active { - background: var(--accent); - color: white; + background: rgba(43, 92, 255, 0.12); + color: var(--fg); } .folder-row .toggle { - width: 1.1rem; - text-align: center; - font-size: 0.9rem; + width: 1.05rem; + display: inline-flex; + align-items: center; + justify-content: center; user-select: none; + flex-shrink: 0; + transition: transform 0.18s ease; } .folder-row .toggle.invisible { visibility: hidden; } +.toggle-icon { + width: 0.9rem; + height: 0.9rem; + transition: transform 0.18s ease; +} + +.folder-row .toggle.expanded .toggle-icon { + transform: rotate(90deg); +} + +.folder-row .icon-button { + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + margin-left: auto; +} + +.folder-row:hover .icon-button, +.folder-row:focus-within .icon-button { + opacity: 1; + pointer-events: auto; +} + .folder-children { list-style: none; - margin: 0 0 0 1rem; + margin: 0 0 0 0.45rem; padding: 0; } @@ -177,73 +307,133 @@ button.icon-button:hover:not([disabled]) { outline-offset: 2px; } -.table-panel { - overflow: hidden; -} - -.table-panel header { +.documents-panel .column-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 0.8rem; - gap: 1rem; } -.header-actions { +.documents-panel .column-header .header-actions { display: flex; gap: 0.5rem; } -.table-panel table { - width: 100%; - border-collapse: collapse; - font-size: 0.95rem; + +.documents-panel .column-body { + display: flex; + flex-direction: column; + gap: 0.5rem; } -.table-panel thead { - background: var(--accent-soft); -} - -.table-panel th, -.table-panel td { - padding: 0.65rem 0.75rem; - text-align: left; +.documents-panel .column-toolbar { + flex-shrink: 0; + padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); } -.table-panel th.actions-column, -.table-panel td.actions { +.documents-panel .documents-scroll { + flex: 1; + overflow-y: auto; + border: none; + background: transparent; + min-height: 0; + padding-right: 0.3rem; +} + +.documents-panel table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; + margin-top: 0.4rem; +} + +.documents-panel thead { + background: transparent; + border-bottom: 1px solid var(--border); +} + +.documents-panel thead th { + position: sticky; + top: 0; + background: var(--surface); + z-index: 1; + padding: 0.45rem 0.6rem; + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + + +.documents-panel th, +.documents-panel td { + padding: 0.45rem 0.6rem; + text-align: left; +} + +.documents-panel th.thumb-column, +.documents-panel td.thumb-cell { + width: 54px; + text-align: center; +} + +.document-thumbnail { + width: 36px; + height: 48px; + object-fit: cover; + border-radius: 0; + box-shadow: none; +} + +.thumb-placeholder { + width: 36px; + height: 48px; + border-radius: 0; + background: var(--surface-subtle); + color: var(--muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.08em; +} + +.documents-panel th.actions-column, +.documents-panel td.actions { width: 1%; white-space: nowrap; } -.table-panel tbody tr { - background: var(--surface-strong); +.documents-panel tbody tr { + background: transparent; + border-bottom: 1px solid var(--border); transition: background 0.15s ease; } -.table-panel tbody tr:nth-child(every) { - background: var(--surface-strong); +.documents-panel tbody tr:last-child { + border-bottom: none; } -.table-panel tbody tr:hover { - background: rgba(37, 99, 235, 0.08); +.documents-panel tbody tr:hover { + background: var(--surface-subtle); } -.table-panel tbody tr.folder { +.documents-panel tbody tr.folder { cursor: pointer; font-weight: 600; } -.table-panel tbody tr.document { +.documents-panel tbody tr.document { cursor: pointer; } -.table-panel tbody tr.document.selected { - background: rgba(37, 99, 235, 0.2); +.documents-panel tbody tr.document.selected { + background: rgba(43, 92, 255, 0.12); + box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.9); } -.table-panel tbody tr.document.dragging { +.documents-panel tbody tr.document.dragging { opacity: 0.4; } @@ -251,34 +441,34 @@ button.icon-button:hover:not([disabled]) { display: flex; flex-wrap: wrap; align-items: center; - gap: 0.6rem; - margin-bottom: 1rem; + gap: 0.32rem; + margin-bottom: 0.5rem; } .filter-bar input[type='search'] { flex: 1; - min-width: 220px; + min-width: 180px; } .tag-filters { display: flex; flex-wrap: wrap; - gap: 0.4rem; + gap: 0.32rem; } .tag-filter { - border: 1px solid var(--accent); + border: 1px solid var(--border); background: transparent; - color: var(--accent); - padding: 0.3rem 0.7rem; - border-radius: 999px; + color: var(--fg); + padding: 0.25rem 0.6rem; + border-radius: 2px; cursor: pointer; font-size: 0.85rem; transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; } .tag-filter:hover { - box-shadow: 0 8px 16px var(--accent-strong); + background: var(--surface-subtle); } .tag-filter.active { @@ -288,7 +478,7 @@ button.icon-button:hover:not([disabled]) { .filter-actions { display: flex; - gap: 0.4rem; + gap: 0.32rem; } .search-hint { @@ -300,29 +490,53 @@ button.icon-button:hover:not([disabled]) { .badge { display: inline-flex; align-items: center; - padding: 0.15rem 0.5rem; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent); - font-size: 0.75rem; - margin-right: 0.35rem; + padding: 0.15rem 0.35rem; + border-radius: 2px; + background: var(--surface-subtle); + color: var(--fg); + font-size: 0.74rem; + margin-right: 0.25rem; } .empty-state { - border: 2px dashed var(--border); - border-radius: 12px; + border: 1px dashed var(--border); + border-radius: 0; text-align: center; - padding: 2.5rem 1rem; + padding: 1.1rem 0.9rem; color: var(--muted); - margin-top: 1.5rem; + margin-top: 0.75rem; + background: var(--surface-subtle); } .detail-panel { position: relative; - min-height: calc(100vh - 6rem); +} + +.detail-panel .column-body { + gap: 1rem; +} + +.detail-panel .column-body.scrollable { + overflow-y: auto; +} + +.detail-actions { + display: flex; + gap: 0.32rem; + margin: 0.6rem 0; +} + +.thumbnail-preview { display: flex; flex-direction: column; - gap: 1rem; + gap: 0.32rem; + margin: 0.4rem 0 0.6rem; +} + +.thumbnail-image { + max-width: 100%; + border-radius: 0; + box-shadow: none; } .detail-panel .meta { @@ -346,25 +560,26 @@ button.icon-button:hover:not([disabled]) { .detail-panel .tag-list { display: flex; flex-wrap: wrap; - gap: 0.4rem; - margin-top: 0.6rem; + gap: 0.3rem; + margin-top: 0.5rem; } .tag-pill { display: inline-flex; align-items: center; - gap: 0.4rem; - background: var(--accent-soft); - color: var(--accent); - padding: 0.2rem 0.55rem; - border-radius: 999px; - font-size: 0.8rem; + gap: 0.18rem; + background: var(--surface-subtle); + color: var(--fg); + padding: 0.2rem 0.45rem; + border-radius: 2px; + font-size: 0.78rem; + border: 1px solid var(--border); } .tag-pill button { background: none; border: none; - color: inherit; + color: var(--muted); padding: 0; cursor: pointer; font-size: 0.85rem; @@ -374,52 +589,61 @@ input, textarea, select { font: inherit; - border-radius: 8px; + border-radius: 2px; border: 1px solid var(--border); - padding: 0.55rem 0.6rem; - background: var(--surface-strong); + padding: 0.4rem 0.5rem; + background: var(--surface); color: inherit; width: 100%; box-sizing: border-box; } +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft); + background: var(--surface); +} + textarea { resize: vertical; } form.inline { display: flex; - gap: 0.5rem; - margin-top: 0.75rem; + gap: 0.32rem; + margin-top: 0.5rem; } .status-banner { - padding: 0.6rem 1rem; - border-radius: 10px; - font-size: 0.9rem; - margin-bottom: 1rem; + padding: 0.45rem 0.8rem; + border-radius: 2px; + font-size: 0.85rem; + margin-bottom: 0.8rem; } .status-banner.info { - background: rgba(107, 114, 128, 0.12); + background: var(--surface-subtle); color: var(--muted); } .status-banner.success { - background: rgba(16, 185, 129, 0.15); + background: rgba(18, 128, 90, 0.1); color: var(--success); } .status-banner.error { - background: rgba(220, 38, 38, 0.15); + background: rgba(217, 44, 32, 0.1); color: var(--danger); } .drop-overlay { position: fixed; inset: 0; - background: rgba(37, 99, 235, 0.25); - backdrop-filter: blur(8px); + background: rgba(43, 92, 255, 0.12); + backdrop-filter: blur(4px); display: none; align-items: center; justify-content: center; @@ -431,17 +655,18 @@ form.inline { } .drop-overlay__content { - background: rgba(17, 24, 39, 0.85); - color: white; - padding: 2rem 3rem; - border-radius: 20px; + background: var(--surface); + color: var(--fg); + padding: 1.25rem 1.75rem; + border-radius: 2px; text-align: center; - font-size: 1.2rem; - box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35); + font-size: 0.95rem; + box-shadow: none; + border: 1px solid var(--border); } @media (max-width: 1080px) { - .dashboard-layout { + .app-main { grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); grid-template-rows: auto auto; } @@ -451,19 +676,84 @@ form.inline { } @media (max-width: 768px) { - #app { - padding: 1.5rem 1rem 4rem; - } - header.global-header { + .app-bar { flex-direction: column; align-items: flex-start; + gap: 0.75rem; } - .dashboard-layout { + .app-main { grid-template-columns: 1fr; } .sidebar, - .table-panel, + .documents-panel, .detail-panel { min-height: auto; } } +.sidebar .column-body { + gap: 0.28rem; +} + +.new-folder-form { + display: flex; + gap: 0.45rem; +} + +.folder-tree-scroller { + flex: 1; + overflow-y: auto; + padding-right: 0.4rem; + margin-right: -0.4rem; + min-height: 0; +} +.login-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + padding: 2rem; +} + +.login-card { + width: min(340px, 100%); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 2px; + padding: 1.75rem; + box-shadow: none; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.login-card h1 { + margin: 0; + font-size: 1.6rem; + font-weight: 600; +} + +.login-card p { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.login-card form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.login-card label { + font-size: 0.85rem; + font-weight: 600; + color: var(--muted); +} + +.login-card .status-banner { + margin-bottom: 0; +} +.column-toolbar .filter-bar { + margin-bottom: 0; +} diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 9689202..a119093 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -9,7 +9,7 @@ const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || 'http://127.0.0.1:3000'; module.exports = { - entry: './src/index.js', + entry: './src/index.jsx', output: { filename: 'bundle.[contenthash].js', path: path.resolve(__dirname, 'dist'), @@ -17,6 +17,13 @@ module.exports = { }, module: { rules: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + }, + }, { test: /\.css$/i, use: ['style-loader', 'css-loader'], @@ -49,4 +56,7 @@ module.exports = { ], }, devtool: 'source-map', + resolve: { + extensions: ['.js', '.jsx'], + }, };