diff --git a/backend/migrations/202503120001_drop_asset_dimensions/down.sql b/backend/migrations/202503120001_drop_asset_dimensions/down.sql new file mode 100644 index 0000000..a7be2e1 --- /dev/null +++ b/backend/migrations/202503120001_drop_asset_dimensions/down.sql @@ -0,0 +1,13 @@ +-- Restore width/height columns and repopulate from metadata where available. + +ALTER TABLE document_assets + ADD COLUMN width INTEGER, + ADD COLUMN height INTEGER; + +UPDATE document_assets +SET width = (metadata->>'width')::INTEGER +WHERE metadata ? 'width'; + +UPDATE document_assets +SET height = (metadata->>'height')::INTEGER +WHERE metadata ? 'height'; diff --git a/backend/migrations/202503120001_drop_asset_dimensions/up.sql b/backend/migrations/202503120001_drop_asset_dimensions/up.sql new file mode 100644 index 0000000..d8b05f9 --- /dev/null +++ b/backend/migrations/202503120001_drop_asset_dimensions/up.sql @@ -0,0 +1,15 @@ +-- Backfill existing width/height values into metadata then drop the columns. + +UPDATE document_assets +SET metadata = metadata || jsonb_build_object('width', width) +WHERE width IS NOT NULL + AND NOT (metadata ? 'width'); + +UPDATE document_assets +SET metadata = metadata || jsonb_build_object('height', height) +WHERE height IS NOT NULL + AND NOT (metadata ? 'height'); + +ALTER TABLE document_assets + DROP COLUMN width, + DROP COLUMN height; diff --git a/backend/migrations/202503120003_use_version_uuid/down.sql b/backend/migrations/202503120003_use_version_uuid/down.sql new file mode 100644 index 0000000..25bffa4 --- /dev/null +++ b/backend/migrations/202503120003_use_version_uuid/down.sql @@ -0,0 +1,17 @@ +ALTER TABLE documents ADD COLUMN current_version INT4; + +UPDATE documents AS d +SET current_version = dv.version_number +FROM document_versions AS dv +WHERE dv.id = d.current_version_id; + +ALTER TABLE documents + ALTER COLUMN current_version SET NOT NULL; + +DROP INDEX IF EXISTS idx_documents_current_version_id; + +ALTER TABLE documents + DROP CONSTRAINT IF EXISTS documents_current_version_fk; + +ALTER TABLE documents + DROP COLUMN current_version_id; diff --git a/backend/migrations/202503120003_use_version_uuid/up.sql b/backend/migrations/202503120003_use_version_uuid/up.sql new file mode 100644 index 0000000..101ef2d --- /dev/null +++ b/backend/migrations/202503120003_use_version_uuid/up.sql @@ -0,0 +1,21 @@ +ALTER TABLE documents ADD COLUMN current_version_id UUID; + +UPDATE documents AS d +SET current_version_id = dv.id +FROM document_versions AS dv +WHERE dv.document_id = d.id + AND dv.version_number = d.current_version; + +ALTER TABLE documents + ALTER COLUMN current_version_id SET NOT NULL; + +ALTER TABLE documents + ADD CONSTRAINT documents_current_version_fk + FOREIGN KEY (current_version_id) + REFERENCES document_versions(id) + DEFERRABLE INITIALLY DEFERRED; + +CREATE INDEX idx_documents_current_version_id + ON documents(current_version_id); + +ALTER TABLE documents DROP COLUMN current_version; diff --git a/backend/src/models.rs b/backend/src/models.rs index ec0202e..d0d4187 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -53,7 +53,7 @@ pub struct Document { pub original_name: String, pub content_type: Option, pub folder_id: Option, - pub current_version: i32, + pub current_version_id: Uuid, pub uploaded_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub deleted_at: Option, @@ -70,7 +70,7 @@ pub struct NewDocument { pub original_name: String, pub content_type: Option, pub folder_id: Option, - pub current_version: i32, + pub current_version_id: Uuid, pub metadata: serde_json::Value, pub issued_at: Option, pub title: String, @@ -111,8 +111,6 @@ pub struct DocumentAsset { 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, } @@ -125,8 +123,6 @@ pub struct NewDocumentAsset { pub asset_type: String, pub s3_key: String, pub mime_type: String, - pub width: Option, - pub height: Option, pub metadata: serde_json::Value, } diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index dc53ebf..c231349 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -77,40 +77,6 @@ impl From for TagResponse { } } -#[derive(Serialize, Clone, Default)] -pub struct DocumentAssetGroupResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub preview: Option, -} - -impl DocumentAssetGroupResponse { - pub fn is_empty(&self) -> bool { - self.thumbnail.is_none() && self.preview.is_none() - } -} - -#[derive(Serialize)] -pub struct DocumentResponse { - pub id: Uuid, - pub filename: String, - pub title: String, - pub original_name: String, - pub content_type: Option, - pub folder_id: Option, - pub current_version: i32, - pub uploaded_at: String, - pub updated_at: String, - pub deleted_at: Option, - pub issued_at: Option, - pub metadata: Value, - pub tags: Vec, - #[serde(default, skip_serializing_if = "DocumentAssetGroupResponse::is_empty")] - pub assets: DocumentAssetGroupResponse, - pub download_path: String, -} - #[derive(Serialize, Clone)] pub struct DocumentVersionResponse { pub id: Uuid, @@ -127,19 +93,41 @@ pub struct DocumentAssetResponse { pub id: Uuid, pub asset_type: String, pub mime_type: String, - pub width: Option, - pub height: Option, - pub url: String, + pub metadata: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, pub created_at: String, } -type PrimaryDocumentAssets = DocumentAssetGroupResponse; +#[derive(Serialize, Clone)] +pub struct DocumentCurrentVersionResponse { + #[serde(flatten)] + pub version: DocumentVersionResponse, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, + pub download_path: String, +} +#[derive(Serialize)] +pub struct DocumentResponse { + pub id: Uuid, + pub filename: String, + pub title: String, + pub original_name: String, + pub content_type: Option, + pub folder_id: Option, + pub uploaded_at: String, + pub updated_at: String, + pub deleted_at: Option, + pub issued_at: Option, + pub metadata: Value, + pub tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_version: Option, +} #[derive(Serialize)] pub struct DocumentDetailResponse { pub document: DocumentResponse, - pub current_version: DocumentVersionResponse, - pub assets: Vec, } #[derive(Serialize)] @@ -256,18 +244,18 @@ pub async fn list_documents( let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; drop(conn); - let primary_assets = load_primary_assets(&state, &docs).await?; + let primary_versions = load_primary_assets(&state, &docs).await?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); - let assets = primary_assets.get(&doc.id).cloned(); + let current_version = primary_versions.get(&doc.id).cloned(); response.push(to_document_response( &state, user.user_id, doc, tags, - assets, + current_version, )?); } @@ -287,8 +275,7 @@ pub async fn get_document( } let current_version: DocumentVersion = document_versions::table - .filter(document_versions::document_id.eq(document_id)) - .filter(document_versions::version_number.eq(doc.current_version)) + .find(doc.current_version_id) .first(&mut conn)?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; @@ -296,21 +283,7 @@ pub async fn get_document( drop(conn); let assets = load_asset_responses(&state, version_id).await?; - let mut grouped_assets = DocumentAssetGroupResponse::default(); - if let Some(asset) = assets - .iter() - .find(|asset| asset.asset_type == "thumbnail") - .cloned() - { - grouped_assets.thumbnail = Some(asset); - } - if let Some(asset) = assets - .iter() - .find(|asset| asset.asset_type == "preview") - .cloned() - { - grouped_assets.preview = Some(asset); - } + let version_response = to_version_response(current_version); Ok(Json(DocumentDetailResponse { document: to_document_response( @@ -318,10 +291,8 @@ pub async fn get_document( user.user_id, doc, tags_map.get(&document_id).cloned(), - Some(grouped_assets), + Some((version_response, assets)), )?, - current_version: to_version_response(current_version), - assets, })) } @@ -441,17 +412,12 @@ pub async fn request_document_assets( return Err(AppError::not_found()); } - 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)?; - enqueue_job( &mut conn, JOB_ANALYZE_DOCUMENT, json!({ "document_id": document_id, - "document_version_id": version.id, + "document_version_id": document.current_version_id, "force": query.force, }), None, @@ -467,11 +433,9 @@ pub async fn reanalyze_all_documents( ) -> AppResult<(StatusCode, Json)> { let mut conn = state.db()?; - let targets: Vec<(Uuid, Uuid)> = document_versions::table - .inner_join(documents::table.on(document_versions::document_id.eq(documents::id))) + let targets: Vec<(Uuid, Uuid)> = documents::table .filter(documents::deleted_at.is_null()) - .filter(document_versions::version_number.eq(documents::current_version)) - .select((documents::id, document_versions::id)) + .select((documents::id, documents::current_version_id)) .load(&mut conn)?; let mut queued = 0usize; @@ -511,12 +475,10 @@ pub async fn reanalyze_selected_documents( let mut conn = state.db()?; - let targets: Vec<(Uuid, Uuid)> = document_versions::table - .inner_join(documents::table.on(document_versions::document_id.eq(documents::id))) + let targets: Vec<(Uuid, Uuid)> = documents::table .filter(documents::id.eq_any(&document_ids)) .filter(documents::deleted_at.is_null()) - .filter(document_versions::version_number.eq(documents::current_version)) - .select((documents::id, document_versions::id)) + .select((documents::id, documents::current_version_id)) .load(&mut conn)?; if targets.len() != document_ids.len() { @@ -554,16 +516,44 @@ pub async fn list_document_assets( return Err(AppError::not_found()); } - 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 version_id = document.current_version_id; drop(conn); - let assets = load_asset_responses(&state, version.id).await?; + let assets = load_asset_responses(&state, version_id).await?; Ok(Json(assets)) } +pub async fn get_document_asset( + State(state): State, + Path((document_id, asset_id)): Path<(Uuid, Uuid)>, +) -> 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()); + } + + let asset: DocumentAsset = document_assets::table.find(asset_id).first(&mut conn)?; + let version: DocumentVersion = document_versions::table + .find(asset.document_version_id) + .first(&mut conn)?; + + if version.document_id != document_id { + return Err(AppError::not_found()); + } + + let s3_key = asset.s3_key.clone(); + drop(conn); + + let presigned_url = state + .storage + .presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) + .await + .map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?; + + Ok(Json(to_asset_response(asset, Some(presigned_url)))) +} + pub async fn download_document( State(state): State, Path(document_id): Path, @@ -575,8 +565,7 @@ pub async fn download_document( } let version: DocumentVersion = document_versions::table - .filter(document_versions::document_id.eq(document_id)) - .filter(document_versions::version_number.eq(doc.current_version)) + .find(doc.current_version_id) .first(&mut conn)?; let presigned_url = state @@ -614,8 +603,7 @@ pub async fn download_with_token( } let version: DocumentVersion = document_versions::table - .filter(document_versions::document_id.eq(claims.doc_id)) - .filter(document_versions::version_number.eq(doc.current_version)) + .find(doc.current_version_id) .first(&mut conn)?; let now = Utc::now().naive_utc(); @@ -697,8 +685,7 @@ pub async fn update_document( } let current_version: DocumentVersion = document_versions::table - .filter(document_versions::document_id.eq(document_id)) - .filter(document_versions::version_number.eq(document.current_version)) + .find(document.current_version_id) .first(&mut conn)?; let tags_map = load_tags_for_documents(&mut conn, &[document_id])?; @@ -706,21 +693,7 @@ pub async fn update_document( drop(conn); let assets = load_asset_responses(&state, version_id).await?; - let mut grouped_assets = DocumentAssetGroupResponse::default(); - if let Some(asset) = assets - .iter() - .find(|asset| asset.asset_type == "thumbnail") - .cloned() - { - grouped_assets.thumbnail = Some(asset); - } - if let Some(asset) = assets - .iter() - .find(|asset| asset.asset_type == "preview") - .cloned() - { - grouped_assets.preview = Some(asset); - } + let version_response = to_version_response(current_version); Ok(Json(DocumentDetailResponse { document: to_document_response( @@ -728,10 +701,8 @@ pub async fn update_document( user.user_id, document, tags_map.get(&document_id).cloned(), - Some(grouped_assets), + Some((version_response, assets)), )?, - current_version: to_version_response(current_version), - assets, })) } @@ -982,9 +953,8 @@ async fn process_upload( 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))), + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), ) .filter(document_versions::checksum.eq(&checksum_hex)) .select((documents::all_columns, document_versions::all_columns)) @@ -1008,21 +978,7 @@ async fn process_upload( let tags = tags_map.get(&document.id).cloned(); drop(conn); let assets = load_asset_responses(state, version.id).await?; - let mut grouped_assets = DocumentAssetGroupResponse::default(); - if let Some(asset) = assets - .iter() - .find(|asset| asset.asset_type == "thumbnail") - .cloned() - { - grouped_assets.thumbnail = Some(asset); - } - if let Some(asset) = assets - .iter() - .find(|asset| asset.asset_type == "preview") - .cloned() - { - grouped_assets.preview = Some(asset); - } + let version_response = to_version_response(version.clone()); info!( document_id = %document.id, @@ -1037,10 +993,8 @@ async fn process_upload( user_id, document, tags, - Some(grouped_assets), + Some((version_response, assets)), )?, - current_version: to_version_response(version), - assets, }, created: false, }); @@ -1078,7 +1032,7 @@ async fn process_upload( original_name: original_name.clone(), content_type: content_type.clone(), folder_id, - current_version: version_number, + current_version_id: version_id, issued_at: None, title: derive_document_title(&original_name), metadata: metadata_value.clone(), @@ -1109,9 +1063,13 @@ async fn process_upload( }; let detail = DocumentDetailResponse { - document: to_document_response(state, user_id, document, None, None)?, - current_version: to_version_response(version.clone()), - assets: Vec::new(), + document: to_document_response( + state, + user_id, + document, + None, + Some((to_version_response(version.clone()), Vec::new())), + )?, }; if let Ok(mut conn) = state.db() { @@ -1171,111 +1129,61 @@ pub(crate) fn load_tags_for_documents( pub(crate) async fn load_primary_assets( state: &AppState, documents: &[Document], -) -> AppResult> { +) -> AppResult)>> { if documents.is_empty() { return Ok(HashMap::new()); } - #[derive(Default)] - struct RawPrimaryAssets { - thumbnail: Option, - preview: Option, + let mut doc_to_version: HashMap = HashMap::with_capacity(documents.len()); + let mut version_ids: Vec = Vec::with_capacity(documents.len()); + for doc in documents { + doc_to_version.insert(doc.id, doc.current_version_id); + version_ids.push(doc.current_version_id); } - 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); - } + version_ids.sort(); + version_ids.dedup(); let mut conn = state.db()?; let versions: Vec = document_versions::table - .filter(document_versions::document_id.eq_any(&doc_ids)) + .filter(document_versions::id.eq_any(&version_ids)) .load(&mut conn)?; - let mut version_id_by_doc: HashMap = HashMap::new(); - let mut doc_id_by_version: HashMap = HashMap::new(); + let mut version_map: 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); - } - } + version_map.insert(version.id, version); } - 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_any(vec!["thumbnail".to_string(), "preview".to_string()]), - ) .order(( document_assets::document_version_id.asc(), document_assets::created_at.asc(), )) .load(&mut conn)?; - let mut raw_assets: HashMap = HashMap::new(); + let mut assets_by_version: HashMap> = HashMap::new(); for asset in assets { - if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) { - let entry = raw_assets.entry(*doc_id).or_default(); - let asset_type = asset.asset_type.clone(); - match asset_type.as_str() { - "thumbnail" => { - if entry.thumbnail.is_none() { - entry.thumbnail = Some(asset); - } - } - "preview" => { - if entry.preview.is_none() { - entry.preview = Some(asset); - } - } - _ => {} - } - } + let version_id = asset.document_version_id; + let response = to_asset_response(asset, None); + assets_by_version + .entry(version_id) + .or_default() + .push(response); } drop(conn); - let mut responses = HashMap::with_capacity(raw_assets.len()); - for (doc_id, raw) in raw_assets { - let mut assets = PrimaryDocumentAssets::default(); - - if let Some(asset) = raw.preview { - let s3_key = asset.s3_key.clone(); - let url = state - .storage - .presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) - .await - .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; - assets.preview = Some(to_asset_response(asset, url)); - } - - if let Some(asset) = raw.thumbnail { - let s3_key = asset.s3_key.clone(); - let url = state - .storage - .presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS)) - .await - .map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?; - assets.thumbnail = Some(to_asset_response(asset, url)); - } - - if assets.thumbnail.is_some() || assets.preview.is_some() { - responses.insert(doc_id, assets); + let mut result: HashMap)> = + HashMap::with_capacity(doc_to_version.len()); + for (doc_id, version_id) in doc_to_version { + if let Some(version) = version_map.remove(&version_id) { + let assets = assets_by_version.remove(&version_id).unwrap_or_default(); + result.insert(doc_id, (to_version_response(version), assets)); } } - Ok(responses) + Ok(result) } pub(crate) fn to_document_response( @@ -1283,9 +1191,18 @@ pub(crate) fn to_document_response( user_id: Uuid, doc: Document, tags: Option>, - assets: Option, + current_version: Option<(DocumentVersionResponse, Vec)>, ) -> AppResult { - let download_path = build_download_path(state, doc.id, user_id)?; + let current_version = if let Some((version, assets)) = current_version { + let download_path = build_download_path(state, doc.id, user_id)?; + Some(DocumentCurrentVersionResponse { + version, + assets, + download_path, + }) + } else { + None + }; Ok(DocumentResponse { id: doc.id, @@ -1294,7 +1211,6 @@ pub(crate) fn to_document_response( original_name: doc.original_name, content_type: doc.content_type, folder_id: doc.folder_id, - current_version: doc.current_version, uploaded_at: to_iso(doc.uploaded_at), updated_at: to_iso(doc.updated_at), deleted_at: doc.deleted_at.map(to_iso), @@ -1305,8 +1221,7 @@ pub(crate) fn to_document_response( .into_iter() .map(TagResponse::from) .collect(), - assets: assets.unwrap_or_default(), - download_path, + current_version, }) } @@ -1330,13 +1245,13 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse { } } -fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse { +fn to_asset_response(asset: DocumentAsset, url: Option) -> DocumentAssetResponse { + let metadata = asset.metadata.clone(); DocumentAssetResponse { id: asset.id, asset_type: asset.asset_type, mime_type: asset.mime_type, - width: asset.width, - height: asset.height, + metadata, url, created_at: to_iso(asset.created_at), } @@ -1369,20 +1284,10 @@ async fn load_asset_responses( .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) + Ok(assets + .into_iter() + .map(|asset| to_asset_response(asset, None)) + .collect()) } pub(crate) fn to_iso(dt: NaiveDateTime) -> String { diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index 170b0e0..cafffbb 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -215,18 +215,18 @@ pub async fn list_folder_contents( let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; drop(conn); - let primary_assets = load_primary_assets(&state, &docs).await?; + let primary_versions = load_primary_assets(&state, &docs).await?; let mut documents = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); - let assets = primary_assets.get(&doc.id).cloned(); + let current_version = primary_versions.get(&doc.id).cloned(); documents.push(to_document_response( &state, user.user_id, doc, tags, - assets, + current_version, )?); } @@ -399,17 +399,17 @@ pub async fn search_documents( let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?; drop(conn); - let primary_assets = load_primary_assets(&state, &docs).await?; + let primary_versions = load_primary_assets(&state, &docs).await?; let mut response = Vec::with_capacity(doc_ids.len()); for doc in docs { let tags = tags_map.get(&doc.id).cloned(); - let assets = primary_assets.get(&doc.id).cloned(); + let current_version = primary_versions.get(&doc.id).cloned(); response.push(to_document_response( &state, user.user_id, doc, tags, - assets, + current_version, )?); } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 95f0643..f0dcc46 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -69,6 +69,7 @@ pub fn create_router(state: AppState) -> Router<()> { .patch(documents::update_document), ) .route("/:id/download", get(documents::download_document)) + .route("/:id/assets/:asset_id", get(documents::get_document_asset)) .route( "/:id/assets", get(documents::list_document_assets).post(documents::request_document_assets), diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 4ee36a9..9dd0f66 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -7,8 +7,6 @@ diesel::table! { asset_type -> Text, s3_key -> Text, mime_type -> Text, - width -> Nullable, - height -> Nullable, metadata -> Jsonb, created_at -> Timestamptz, } @@ -48,7 +46,6 @@ diesel::table! { #[max_length = 100] content_type -> Nullable, folder_id -> Nullable, - current_version -> Int4, uploaded_at -> Timestamptz, updated_at -> Timestamptz, deleted_at -> Nullable, @@ -56,6 +53,7 @@ diesel::table! { issued_at -> Nullable, #[max_length = 255] title -> Varchar, + current_version_id -> Uuid, } } @@ -128,7 +126,6 @@ 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)); -diesel::joinable!(document_versions -> documents (document_id)); diesel::joinable!(documents -> folders (folder_id)); diesel::joinable!(refresh_tokens -> users (user_id)); diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs index 25ce03f..130bf33 100644 --- a/backend/src/workers/ocr.rs +++ b/backend/src/workers/ocr.rs @@ -377,8 +377,6 @@ fn persist_ocr_metadata( asset_type: OCR_TEXT_ASSET_TYPE.to_string(), s3_key: s3_key.to_string(), mime_type: "text/plain".to_string(), - width: None, - height: None, metadata: json!({ "generated_at": Utc::now().to_rfc3339(), "source": source, diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs index 348b540..40db901 100644 --- a/backend/src/workers/thumbnails.rs +++ b/backend/src/workers/thumbnails.rs @@ -405,10 +405,10 @@ fn persist_assets_metadata( asset_type: asset.asset_type.to_string(), s3_key: asset.s3_key.to_string(), mime_type: "image/png".to_string(), - width: asset.generated.width, - height: asset.generated.height, metadata: json!({ "generated_at": Utc::now().to_rfc3339(), + "width": asset.generated.width, + "height": asset.generated.height, }), }; @@ -422,8 +422,6 @@ fn persist_assets_metadata( .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) diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs index a20cf57..f366617 100644 --- a/backend/tests/documents_flow.rs +++ b/backend/tests/documents_flow.rs @@ -9,8 +9,6 @@ use uuid::Uuid; #[derive(Deserialize)] struct DocumentDetail { document: DocumentInfo, - current_version: DocumentVersion, - assets: Vec, } #[derive(Deserialize)] @@ -18,11 +16,11 @@ struct DocumentInfo { id: Uuid, title: String, original_name: String, - current_version: i32, deleted_at: Option, issued_at: Option, tags: Vec, - download_path: String, + #[serde(default)] + current_version: Option, } #[derive(Deserialize)] @@ -30,6 +28,10 @@ struct DocumentVersion { id: Uuid, s3_key: String, size_bytes: i64, + version_number: i32, + download_path: String, + #[serde(default)] + assets: Vec, } #[allow(dead_code)] @@ -42,8 +44,8 @@ struct DocumentAssetInfo { #[derive(Deserialize)] struct DocumentListItem { id: Uuid, - current_version: i32, - download_path: String, + #[serde(default)] + current_version: Option, } #[derive(Deserialize)] @@ -152,17 +154,22 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(detail.document.original_name, "doc.txt"); assert_eq!(detail.document.title, "doc"); - assert_eq!(detail.document.current_version, 1); assert_eq!(detail.document.deleted_at, None); assert!(detail.document.issued_at.is_none()); assert!(detail.document.tags.is_empty()); - assert!(detail.document.download_path.starts_with("/download/")); - assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64); - assert!(detail.assets.is_empty()); + assert!(current_version.download_path.starts_with("/download/")); + let current_version = detail + .document + .current_version + .as_ref() + .expect("current version detail"); + assert_eq!(current_version.version_number, 1); + assert_eq!(current_version.size_bytes, file_bytes.len() as i64); + assert!(current_version.assets.is_empty()); let stored = app .storage() - .get(&detail.current_version.s3_key) + .get(¤t_version.s3_key) .await .expect("object stored"); assert_eq!(stored.bytes, file_bytes); @@ -175,8 +182,18 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(list.len(), 1); let item = list.pop().unwrap(); assert_eq!(item.id, detail.document.id); - assert_eq!(item.current_version, 1); - assert!(item.download_path.starts_with("/download/")); + assert_eq!( + item.current_version + .as_ref() + .map(|version| version.version_number), + Some(1) + ); + assert!(item + .current_version + .as_ref() + .expect("list current version") + .download_path + .starts_with("/download/")); let download = app .get( @@ -187,17 +204,17 @@ async fn upload_and_list_document() -> Result<()> { assert_eq!(download.status(), StatusCode::OK); let body = body_to_vec(download.into_body()).await?; let download_info: DocumentDownload = serde_json::from_slice(&body)?; - assert!(download_info.url.contains(&detail.current_version.s3_key)); + assert!(download_info.url.contains(¤t_version.s3_key)); assert_eq!(download_info.filename, "doc.txt"); - let redirect = app.get(&detail.document.download_path, None).await?; + let redirect = app.get(¤t_version.download_path, None).await?; assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); let location = redirect .headers() .get("location") .expect("redirect location header"); let location = location.to_str().expect("location header utf8"); - assert!(location.contains(&detail.current_version.s3_key)); + assert!(location.contains(¤t_version.s3_key)); app.cleanup().await?; Ok(()) @@ -243,7 +260,13 @@ 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!(second_detail + .document + .current_version + .as_ref() + .expect("second current version") + .assets + .is_empty()); assert_eq!(app.storage().object_count().await, 1); let delete = app @@ -341,8 +364,24 @@ async fn bulk_reanalyze_documents() -> Result<()> { } let mut expected = vec![ - (first_detail.document.id, first_detail.current_version.id), - (second_detail.document.id, second_detail.current_version.id), + ( + first_detail.document.id, + first_detail + .document + .current_version + .as_ref() + .expect("first current version") + .id, + ), + ( + second_detail.document.id, + second_detail + .document + .current_version + .as_ref() + .expect("second current version") + .id, + ), ]; payload_docs.sort(); expected.sort(); @@ -661,8 +700,24 @@ async fn bulk_reanalyze_selected_documents() -> Result<()> { .all(|(doc_id, _)| *doc_id != second_detail.document.id)); let mut expected = vec![ - (first_detail.document.id, first_detail.current_version.id), - (third_detail.document.id, third_detail.current_version.id), + ( + first_detail.document.id, + first_detail + .document + .current_version + .as_ref() + .expect("first current version") + .id, + ), + ( + third_detail.document.id, + third_detail + .document + .current_version + .as_ref() + .expect("third current version") + .id, + ), ]; payload_docs.sort(); expected.sort(); diff --git a/frontend/src/asset_manager.js b/frontend/src/asset_manager.js new file mode 100644 index 0000000..0347deb --- /dev/null +++ b/frontend/src/asset_manager.js @@ -0,0 +1,257 @@ +export const getAssetFromGroup = (assets, assetType) => { + if (!assetType || !assets) { + return null; + } + + if (Array.isArray(assets)) { + return assets.find((entry) => entry?.asset_type === assetType) || null; + } + + return assets?.[assetType] || null; +}; + +export const getAssetFromVersion = (currentVersion, assetType) => { + if (!currentVersion) { + return null; + } + return getAssetFromGroup(currentVersion.assets, assetType); +}; + +class AssetManager { + constructor({ api, assetPresignTtlMs }) { + this.api = api; + this.assetPresignTtlMs = assetPresignTtlMs; + this.assetCache = new Map(); + this.assetInflight = new Map(); + this.previewCache = new Map(); + this.previewInflight = new Map(); + } + + setApi(api) { + this.api = api; + } + + rememberAsset(entry) { + if (entry?.id) { + this.assetCache.set(entry.id, entry); + } + } + + hydrateAsset(asset) { + if (!asset || !asset.id) { + return asset; + } + const cached = this.assetCache.get(asset.id); + if (!cached) { + return asset; + } + const merged = { ...cached, ...asset }; + if (cached.url && !asset.url) { + merged.url = cached.url; + } + if (cached.expiresAt) { + const cachedExpires = Number(cached.expiresAt) || null; + const assetExpires = Number(asset.expiresAt) || null; + if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) { + merged.expiresAt = cachedExpires; + } + } + return merged; + } + + hydrateDocument(document) { + if (!document) { + return document; + } + + const currentVersion = document.current_version || null; + if (!currentVersion) { + return document; + } + + let changed = false; + let nextAssets = currentVersion.assets; + + if (nextAssets && !Array.isArray(nextAssets)) { + const hydrated = {}; + Object.keys(nextAssets).forEach((key) => { + hydrated[key] = this.hydrateAsset(nextAssets[key]); + if (hydrated[key] !== nextAssets[key]) { + changed = true; + } + }); + if (changed) { + nextAssets = { ...nextAssets, ...hydrated }; + } + } else if (Array.isArray(nextAssets)) { + const hydratedList = nextAssets.map((item) => this.hydrateAsset(item)); + if ( + hydratedList.length !== nextAssets.length || + hydratedList.some((item, index) => item !== nextAssets[index]) + ) { + changed = true; + nextAssets = hydratedList; + } + } + + if (!changed) { + return document; + } + + const nextCurrentVersion = { ...currentVersion, assets: nextAssets }; + return { ...document, current_version: nextCurrentVersion }; + } + + hydrateDocuments(documents) { + if (!Array.isArray(documents)) { + return documents; + } + return documents.map((doc) => this.hydrateDocument(doc)); + } + + hydrateDetail(detail) { + if (!detail) { + return detail; + } + let changed = false; + const next = { ...detail }; + + if (detail.document) { + const hydratedDocument = this.hydrateDocument(detail.document); + if (hydratedDocument !== detail.document) { + next.document = hydratedDocument; + changed = true; + } + } + + if (Array.isArray(detail.assets)) { + const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item)); + if ( + hydratedAssets.length !== detail.assets.length || + hydratedAssets.some((item, index) => item !== detail.assets[index]) + ) { + next.assets = hydratedAssets; + changed = true; + } + } + + return changed ? next : detail; + } + + hydrateFolderContents(contents) { + if (!contents) { + return contents; + } + const next = { ...contents }; + if (Array.isArray(contents.documents)) { + next.documents = this.hydrateDocuments(contents.documents); + } + if (contents.document) { + next.document = this.hydrateDocument(contents.document); + } + return next; + } + + ensureAsset(documentId, asset, { force = false } = {}) { + if (!documentId || !asset?.id) { + return Promise.resolve(asset || null); + } + + const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; + if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) { + this.rememberAsset(asset); + return Promise.resolve(asset); + } + + const cached = this.assetCache.get(asset.id); + const now = Date.now(); + if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) { + return Promise.resolve({ ...asset, ...cached }); + } + + const inflightKey = `${documentId}:${asset.id}`; + if (!force && this.assetInflight.has(inflightKey)) { + return this.assetInflight.get(inflightKey); + } + + if (!this.api) { + return Promise.reject(new Error('AssetManager API client is not configured.')); + } + + const request = this.api + .get(`/documents/${documentId}/assets/${asset.id}`) + .then(({ data }) => { + const entry = { + ...asset, + ...data, + expiresAt: Date.now() + this.assetPresignTtlMs, + }; + this.rememberAsset(entry); + return entry; + }) + .finally(() => { + this.assetInflight.delete(inflightKey); + }); + + this.assetInflight.set(inflightKey, request); + return request; + } + + ensurePreview(documentId, { force = false } = {}) { + if (!documentId) { + return Promise.resolve(null); + } + const cached = this.previewCache.get(documentId); + const now = Date.now(); + if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) { + return Promise.resolve(cached); + } + + if (!force && this.previewInflight.has(documentId)) { + return this.previewInflight.get(documentId); + } + + if (!this.api) { + return Promise.reject(new Error('AssetManager API client is not configured.')); + } + + const request = this.api + .get(`/documents/${documentId}/download`) + .then(({ data }) => { + const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000; + const entry = { + url: data.url, + contentType: data.content_type || null, + filename: data.filename, + expiresAt: Date.now() + ttl, + }; + this.previewCache.set(documentId, entry); + return entry; + }) + .finally(() => { + this.previewInflight.delete(documentId); + }); + + this.previewInflight.set(documentId, request); + return request; + } + + getPreview(documentId) { + return this.previewCache.get(documentId) || null; + } + + deleteDocument(documentId) { + if (!documentId) return; + this.previewCache.delete(documentId); + this.previewInflight.delete(documentId); + } + + reset() { + this.assetCache.clear(); + this.assetInflight.clear(); + this.previewCache.clear(); + this.previewInflight.clear(); + } +} + +export default AssetManager; diff --git a/frontend/src/hooks/useApiError.js b/frontend/src/hooks/useApiError.js new file mode 100644 index 0000000..3da486e --- /dev/null +++ b/frontend/src/hooks/useApiError.js @@ -0,0 +1,33 @@ +import { useCallback } from 'react'; + +const noop = () => {}; + +const normalizeMessage = (error) => { + if (!error) return 'Something went wrong.'; + if (typeof error === 'string') { + return error; + } + const { response, message } = error; + if (response?.data?.error) return response.data.error; + if (response?.data?.message) return response.data.message; + return message || 'Something went wrong.'; +}; + +const useApiError = ({ + logger = console, + onReport = noop, +} = {}) => { + return useCallback( + (error, { message, variant = 'error', retry = null } = {}) => { + const normalizedMessage = message || normalizeMessage(error); + if (logger && typeof logger.error === 'function') { + logger.error('[API]', normalizedMessage, error); + } + onReport({ message: normalizedMessage, variant, retry, error }); + return normalizedMessage; + }, + [logger, onReport], + ); +}; + +export default useApiError; diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 3bb5321..4f6e76f 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -21,6 +21,9 @@ import { matchPath, } from 'react-router-dom'; import './styles.css'; +import AssetManager, { getAssetFromVersion } from './asset_manager'; +import useApiError from './hooks/useApiError'; +import SkeuomorphicWorkspace from './skeuomorphic_ws'; const runtimeApiBase = typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL @@ -28,6 +31,7 @@ const runtimeApiBase = : ''; const DEFAULT_DEV_API = 'http://127.0.0.1:3000'; +const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const API_ROOT = (runtimeApiBase || process.env.API_BASE_URL || DEFAULT_DEV_API).replace(/\/$/, ''); @@ -171,6 +175,7 @@ const getTagColorStyle = (hex) => { }; }; + const IconChevronRight = ({ className }) => ( ); +const DocumentThumbnailImage = ({ documentId, asset, ensureAssetUrl, alt }) => { + useEffect(() => { + if (!documentId || !asset?.id || !ensureAssetUrl) { + return; + } + const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; + const now = Date.now(); + const needsRefresh = !asset?.url || (expiresAt && expiresAt <= now); + if (!needsRefresh) { + return; + } + const force = Boolean(asset.url && expiresAt && expiresAt <= now); + ensureAssetUrl(documentId, asset, { force }).catch(() => {}); + }, [documentId, asset?.id, asset?.url, asset?.expiresAt, ensureAssetUrl]); + + if (asset?.url) { + return ( + {alt + ); + } + + return
DOC
; +}; + const DocumentsTable = ({ currentFolderName, breadcrumbs, onRefresh, + onShowSkeuoWorkspace = () => {}, subfolders, documents, searchResults, @@ -527,9 +561,16 @@ const DocumentsTable = ({ onDocumentListFocus, onDocumentListKeyDown, onFocusedRowChange, + ensureAssetUrl = null, + getDocumentAsset = () => null, }) => { const showingSearchResults = searchResults !== null; const rows = showingSearchResults ? searchResults : documents; + + const resolveThumbnailAsset = useCallback((doc) => { + if (!doc) return null; + return getDocumentAsset(doc, 'thumbnail'); + }, [getDocumentAsset]); const selectedSet = useMemo( () => new Set(selectedDocumentIds), [selectedDocumentIds], @@ -625,6 +666,9 @@ const DocumentsTable = ({ +
@@ -722,7 +766,7 @@ const DocumentsTable = ({ const isSelected = selectedSet.has(doc.id); const rowClasses = ['document']; if (isSelected) rowClasses.push('selected'); - if ( + if ( focusedDocumentId === doc.id || focusedRowKey === `document:${doc.id}` ) { rowClasses.push('focused'); @@ -731,7 +775,7 @@ const DocumentsTable = ({ rowClasses.push('dragging'); } - const thumbnailAsset = doc?.assets?.thumbnail || null; + const thumbnailAsset = resolveThumbnailAsset(doc); return ( {thumbnailAsset ? ( - {`Thumbnail ) : (
DOC
@@ -796,10 +841,10 @@ const DocumentsTable = ({ }
- {doc.download_path ? ( - event.stopPropagation()} @@ -953,6 +998,8 @@ const DetailPanel = ({ onPromoteSelection, activePreviewId = null, onUpdateTitle = async () => false, + ensureAssetUrl = null, + getDocumentAsset = () => null, }) => { const lookup = detailMap && typeof detailMap.get === 'function' ? detailMap : new Map(); const selectedCount = selectedDocuments.length; @@ -1029,18 +1076,20 @@ const DetailPanel = ({ [onPromoteSelection], ); - const makePreviewItem = useCallback((doc, detailEntry, fallbackUrl = null) => { + const resolvePreviewAsset = useCallback((doc) => { if (!doc) return null; - const detailDocument = detailEntry?.document || null; - const thumbnailAsset = - doc?.assets?.thumbnail || - detailDocument?.assets?.thumbnail || - detailEntry?.assets?.find((item) => item.asset_type === 'thumbnail') || - null; - const url = thumbnailAsset?.url || fallbackUrl; - if (!url) return null; - const width = thumbnailAsset?.width || 0; - const height = thumbnailAsset?.height || 0; + return getDocumentAsset(doc, 'preview'); + }, [getDocumentAsset]); + + const makePreviewItem = useCallback((doc) => { + if (!doc) return null; + const previewAsset = resolvePreviewAsset(doc); + if (!previewAsset?.url) { + return null; + } + const url = previewAsset.url; + const width = Number(previewAsset?.metadata?.width) || 0; + const height = Number(previewAsset?.metadata?.height) || 0; const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape'; return { id: doc.id, @@ -1048,7 +1097,7 @@ const DetailPanel = ({ orientation, alt: doc.title || doc.original_name || 'Document preview', }; - }, []); + }, [resolvePreviewAsset]); const stackDocuments = useMemo(() => { if (!selectedDocuments.length) return []; @@ -1068,18 +1117,43 @@ const DetailPanel = ({ const singlePreviewItems = useMemo(() => { if (!singleDoc) return []; - const item = makePreviewItem(singleDoc, detail, previewEntry?.url || null); + const item = makePreviewItem(singleDoc); return item ? [item] : []; - }, [singleDoc, detail, previewEntry, makePreviewItem]); + }, [singleDoc, makePreviewItem]); const stackPreviews = useMemo( () => stackDocuments - .map((doc) => makePreviewItem(doc, lookup.get(doc.id))) + .map((doc) => makePreviewItem(doc)) .filter(Boolean), - [stackDocuments, lookup, makePreviewItem], + [stackDocuments, makePreviewItem], ); + useEffect(() => { + if (!ensureAssetUrl) { + return; + } + + stackDocuments.forEach((doc) => { + const docId = doc?.id; + if (!docId) { + return; + } + const asset = resolvePreviewAsset(doc); + if (!asset?.id) { + return; + } + const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; + const now = Date.now(); + const needsRefresh = !asset.url || (expiresAt && expiresAt <= now); + if (!needsRefresh) { + return; + } + const force = Boolean(asset.url && expiresAt && expiresAt <= now); + ensureAssetUrl(docId, asset, { force }).catch(() => {}); + }); + }, [stackDocuments, resolvePreviewAsset, ensureAssetUrl]); + const commonTags = useMemo(() => { if (selectedCount < 2) return []; const tagSets = selectedDocuments.map((doc) => new Set((doc.tags || []).map((tag) => tag.label))); @@ -1113,8 +1187,8 @@ const DetailPanel = ({ } const displayName = singleDoc.title || singleDoc.original_name; - const downloadHref = singleDoc.download_path - ? resolveApiPath(singleDoc.download_path) + const downloadHref = singleDoc.current_version?.download_path + ? resolveApiPath(singleDoc.current_version.download_path) : null; const isEditingTitle = titleEditDocId === singleDoc.id; @@ -1421,8 +1495,8 @@ const PreviewWorkspace = ({ detail?.document?.original_name || document.original_name; const mime = previewEntry?.contentType || document.content_type || 'application/pdf'; - const downloadHref = document.download_path - ? resolveApiPath(document.download_path) + const downloadHref = document.current_version?.download_path + ? resolveApiPath(document.current_version.download_path) : null; return ( @@ -1835,6 +1909,18 @@ const AppLayout = () => { const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); }, []); + const handleApiReport = useCallback( + ({ message, variant }) => setStatusMessage(message, variant), + [setStatusMessage], + ); + const reportApiError = useApiError({ + onReport: handleApiReport, + }); + const notifyApiError = useCallback( + (error, fallbackMessage, variant = 'error') => + reportApiError(error, { message: fallbackMessage, variant }), + [reportApiError], + ); const [loading, setLoading] = useState(false); const [folderNodes, setFolderNodes] = useState(() => { const rootNode = createRootNode(); @@ -1845,6 +1931,7 @@ const AppLayout = () => { const [currentFolder, setCurrentFolder] = useState(null); const [currentSubfolders, setCurrentSubfolders] = useState([]); const [documents, setDocuments] = useState([]); + const [workspaceMode, setWorkspaceMode] = useState('table'); const initialSelection = routeDocumentId ? [routeDocumentId] : []; const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialSelection); const [selectionOrder, setSelectionOrder] = useState(initialSelection); @@ -1864,7 +1951,7 @@ const AppLayout = () => { const { data } = await api.post('/auth/refresh'); if (data?.access_token) { appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token }); - console.log('[Auth] Access token refreshed'); + console.log('[Auth] Access token refreshed at', new Date().toISOString()); return data.access_token; } throw new Error('Missing access token in refresh response'); @@ -1887,6 +1974,117 @@ const AppLayout = () => { const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); const [previewDocumentId, setPreviewDocumentId] = useState(null); const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false); + const shellRef = useRef(null); + const assetManagerRef = useRef(null); + if (!assetManagerRef.current) { + assetManagerRef.current = new AssetManager({ api, assetPresignTtlMs: ASSET_PRESIGN_TTL_MS }); + } + const assetManager = assetManagerRef.current; + + const getDocumentAsset = useCallback((doc, type) => { + if (!doc || !type) return null; + return getAssetFromVersion(doc.current_version || null, type); + }, []); + + const isAssetEquivalent = (lhs, rhs) => { + if (!lhs || !rhs) return false; + return ( + lhs.id === rhs.id && + lhs.url === rhs.url && + lhs?.metadata?.width === rhs?.metadata?.width && + lhs?.metadata?.height === rhs?.metadata?.height && + lhs.mime_type === rhs.mime_type && + lhs.asset_type === rhs.asset_type && + lhs.created_at === rhs.created_at + ); + }; + + const mergeAssetIntoGroup = (group, assetData) => { + if (!assetData || !assetData.asset_type) { + if (Array.isArray(group)) { + return group; + } + return group || {}; + } + + if (Array.isArray(group) || !group) { + const list = Array.isArray(group) ? group : []; + const index = list.findIndex((item) => item?.id === assetData.id); + if (index >= 0) { + const existing = list[index]; + if (isAssetEquivalent(existing, assetData)) { + return list; + } + const next = list.slice(); + next[index] = { ...existing, ...assetData }; + return next; + } + return list.concat({ ...assetData }); + } + + const key = assetData.asset_type; + const previous = group?.[key]; + if (previous && isAssetEquivalent(previous, assetData)) { + return group; + } + + const next = { ...(group || {}) }; + next[key] = { ...(previous || {}), ...assetData }; + return next; + }; + + const mergeAssetIntoDocument = (doc, assetData) => { + if (!doc) return doc; + const existingGroup = doc.current_version?.assets || null; + const nextGroup = mergeAssetIntoGroup(existingGroup, assetData); + if (nextGroup === existingGroup) { + return doc; + } + const updatedCurrentVersion = doc.current_version + ? { ...doc.current_version, assets: nextGroup } + : { assets: nextGroup }; + return { ...doc, current_version: updatedCurrentVersion }; + }; + + const mergeAssetIntoDetail = (detail, assetData) => { + if (!detail) return detail; + + const nextDocument = detail.document + ? mergeAssetIntoDocument(detail.document, assetData) + : detail.document; + + let nextAssets = detail.assets; + let assetsChanged = false; + + if (Array.isArray(detail.assets)) { + const index = detail.assets.findIndex((item) => item?.id === assetData.id); + if (index >= 0) { + const existing = detail.assets[index]; + if (!isAssetEquivalent(existing, assetData)) { + const copy = detail.assets.slice(); + copy[index] = { ...existing, ...assetData }; + nextAssets = copy; + assetsChanged = true; + } + } else { + nextAssets = detail.assets.concat({ ...assetData }); + assetsChanged = true; + } + } else { + nextAssets = [{ ...assetData }]; + assetsChanged = true; + } + + if (!assetsChanged && nextDocument === detail.document) { + return detail; + } + + return { + ...detail, + document: nextDocument, + assets: assetsChanged ? nextAssets : detail.assets, + }; + }; const bootstrapInitializedRef = useRef(false); const selectionInitializedRef = useRef(false); @@ -1922,11 +2120,13 @@ const AppLayout = () => { setActivePreviewId(null); setPreviewDocumentId(null); setPreviewDocumentLoading(false); + assetManager.reset(); + setPreviewCacheTick(0); dragCounterRef.current = 0; breadcrumbFetchRef.current = new Set(); bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; - }, []); + }, [assetManager]); const tagLookupById = useMemo(() => { const map = new Map(); @@ -2039,10 +2239,17 @@ const AppLayout = () => { config.headers = config.headers || {}; config.headers.Authorization = `Bearer ${newToken}`; console.log('[Auth] Retrying original request', url); - return api(config); + try { + return await api(config); + } catch (retryError) { + if (retryError?.response?.status === 401) { + notifyApiError(retryError, 'Session expired. Please log in again.'); + } + throw retryError; + } } catch (refreshError) { console.warn('[Auth] Refresh failed, clearing session'); - setStatusMessage('Session expired. Please log in again.', 'error'); + notifyApiError(refreshError, 'Session expired. Please log in again.'); return Promise.reject(refreshError); } } @@ -2055,7 +2262,7 @@ const AppLayout = () => { api.interceptors.request.eject(requestInterceptor); api.interceptors.response.eject(responseInterceptor); }; - }, [refreshAccessToken, setStatusMessage]); + }, [notifyApiError, refreshAccessToken]); useEffect(() => { if (!selectedDocumentIds.length) { @@ -2083,7 +2290,7 @@ const AppLayout = () => { const applySelectedFolder = useCallback( (folderId, contents) => { const subfolders = contents?.subfolders ?? []; - const docs = contents?.documents ?? []; + const docs = assetManager.hydrateDocuments(contents?.documents ?? []); const folderInfo = contents?.folder ?? null; setCurrentSubfolders(subfolders); @@ -2127,7 +2334,7 @@ const AppLayout = () => { return nextFocus; }, - [focusedDocumentId, setSelectionOrder], + [assetManager, focusedDocumentId, setSelectionOrder], ); const showingSearchResults = searchResults !== null; @@ -2412,6 +2619,7 @@ const AppLayout = () => { const path = folderId === 'root' ? 'root' : folderId; const { data } = await api.get(`/folders/${path}/contents`); + const hydrated = assetManager.hydrateFolderContents(data); setFolderNodes((prev) => { const next = new Map(prev); @@ -2450,13 +2658,13 @@ const AppLayout = () => { setFolderContents((prev) => { const next = new Map(prev); - next.set(folderId, data); + next.set(folderId, hydrated); return next; }); - return data; + return hydrated; }, - [folderContents], + [assetManager, folderContents], ); const isInvalidFolderDrop = useCallback( @@ -2561,9 +2769,8 @@ const AppLayout = () => { setStatusMessage('Folder moved.', 'success'); } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to move folder.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); const refreshTargets = new Set([previousParentKey, targetKey]); for (const key of refreshTargets) { @@ -2575,7 +2782,7 @@ const AppLayout = () => { } } }, - [api, folderNodes, ensureFolderData, selectedFolder, setSelectedFolder, setStatusMessage, setFolderNodes], + [api, folderNodes, ensureFolderData, selectedFolder, setSelectedFolder, setFolderNodes, notifyApiError], ); const handleFolderDragStart = useCallback( @@ -2609,25 +2816,27 @@ const AppLayout = () => { } const { data } = await api.get(`/documents/${documentId}`); + const hydratedDetail = assetManager.hydrateDetail(data); + const hydratedDocument = hydratedDetail?.document || data.document; setDocumentDetails((prev) => { const next = new Map(prev); - next.set(documentId, data); + next.set(documentId, hydratedDetail); documentDetailsRef.current = next; return next; }); setDocuments((prev) => - prev.map((doc) => (doc.id === documentId ? data.document : doc)), + prev.map((doc) => (doc.id === documentId ? hydratedDocument : doc)), ); setSearchResults((prev) => - prev ? prev.map((doc) => (doc.id === documentId ? data.document : doc)) : null, + prev ? prev.map((doc) => (doc.id === documentId ? hydratedDocument : doc)) : null, ); - return data; + return hydratedDetail; }, - [setDocumentDetails, setDocuments, setSearchResults], + [assetManager, setDocumentDetails, setDocuments, setSearchResults], ); const refreshTags = useCallback(async () => { @@ -2635,10 +2844,9 @@ const AppLayout = () => { const { data } = await api.get('/tags'); setTags(data || []); } catch (error) { - console.error(error); - setStatusMessage('Unable to load tags.', 'error'); + notifyApiError(error, 'Unable to load tags.'); } - }, [setStatusMessage]); + }, [api, notifyApiError]); const handleTagUpdate = useCallback( async (tagId, changes) => { @@ -2664,13 +2872,32 @@ const AppLayout = () => { setStatusMessage('Tag updated.', 'success'); return true; } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to update tag.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); throw new Error(message); } }, - [api, refreshTags, setStatusMessage], + [api, refreshTags, notifyApiError, setStatusMessage], + ); + + const handleTagCreate = useCallback( + async ({ label, color }) => { + const trimmed = (label || '').trim(); + if (!trimmed) { + throw new Error('Tag label is required.'); + } + const payload = { label: trimmed, color: color || null }; + try { + await api.post('/tags', payload); + await refreshTags(); + setStatusMessage('Tag created.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to create tag.'; + notifyApiError(error, message); + throw new Error(message); + } + }, + [api, refreshTags, notifyApiError], ); const loadFolder = useCallback( @@ -2686,13 +2913,12 @@ const AppLayout = () => { await ensureDocumentDetail(nextSelectedId, { force: true }); } } catch (error) { - console.error(error); - setStatusMessage('Failed to load folder contents.', 'error'); + notifyApiError(error, 'Failed to load folder contents.'); } finally { if (showLoading) setLoading(false); } }, - [ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage], + [ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError], ); const selectFolder = useCallback( @@ -2717,13 +2943,12 @@ const AppLayout = () => { const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root'; await loadFolder(initialFolder, { showLoading: false }); } catch (error) { - console.error(error); - setStatusMessage('Failed to initialize data.', 'error'); + notifyApiError(error, 'Failed to initialize data.'); throw error; } finally { setLoading(false); } - }, [refreshTags, routeFolderId, loadFolder, setStatusMessage]); + }, [refreshTags, routeFolderId, loadFolder, notifyApiError]); useEffect(() => { if (!token) { @@ -2762,12 +2987,11 @@ const AppLayout = () => { await ensureDocumentDetail(nextSelectedId, { force: true }); } } catch (error) { - console.error(error); - setStatusMessage('Failed to refresh folder.', 'error'); + notifyApiError(error, 'Failed to refresh folder.'); } finally { setLoading(false); } - }, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]); + }, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError]); useEffect(() => { if (appStatus !== 'authenticated') { @@ -2837,29 +3061,29 @@ const AppLayout = () => { applySelection([], { anchor: null }); await refreshCurrentFolder(); - if (folderId && folderId !== selectedFolder) { - await ensureFolderData(folderId, { force: true }); - } - - event.currentTarget.reset(); - } catch (error) { - console.error(error); - const message = error.response?.data?.error || 'Failed to move documents.'; - setStatusMessage(message, 'error'); - } finally { - setLoading(false); + if (folderId && folderId !== selectedFolder) { + await ensureFolderData(folderId, { force: true }); } - }, - [ - api, - selectedDocumentIds, - folderLabelMap, - applySelection, - refreshCurrentFolder, - ensureFolderData, - selectedFolder, - setStatusMessage, - ], + + event.currentTarget.reset(); + } catch (error) { + const message = error.response?.data?.error || 'Failed to move documents.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [ + api, + selectedDocumentIds, + folderLabelMap, + applySelection, + refreshCurrentFolder, + ensureFolderData, + notifyApiError, + selectedFolder, + setStatusMessage, + ], ); const parseTagInput = useCallback((value) => { @@ -2933,17 +3157,24 @@ const AppLayout = () => { docsCount: selectedDocumentIds.length, }; } catch (error) { - console.error(error); const message = error.response?.data?.error || (action === 'add' ? 'Failed to assign tags.' : 'Failed to remove tags.'); - setStatusMessage(message, 'error'); + notifyApiError(error, message); return { ok: false, reason: 'request-failed' }; } finally { setLoading(false); } }, - [selectedDocumentIds, tags, api, refreshTags, ensureDocumentDetail, setStatusMessage, setLoading], + [ + selectedDocumentIds, + tags, + api, + refreshTags, + ensureDocumentDetail, + notifyApiError, + setLoading, + ], ); const handleBulkTagAdd = useCallback( @@ -3095,14 +3326,13 @@ const AppLayout = () => { 'success', ); } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to queue document re-analysis.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); } finally { setLoading(false); } - }, [selectedDocumentIds, api, setStatusMessage]); + }, [selectedDocumentIds, api, notifyApiError, setStatusMessage]); const uploadFile = useCallback( async (file, targetFolderId) => { @@ -3128,17 +3358,15 @@ const AppLayout = () => { ); return data; } catch (error) { - console.error(error); const message = error.response?.data?.error || `Failed to upload ${file.name}.`; - setStatusMessage(message, 'error'); + notifyApiError(error, message); throw error; } }, - [setStatusMessage], + [notifyApiError, setStatusMessage], ); const folderPathCacheRef = useRef(new Map()); - const previewCacheRef = useRef(new Map()); const [previewCacheTick, setPreviewCacheTick] = useState(0); const ensureFolderPathOnServer = useCallback( @@ -3167,36 +3395,75 @@ const AppLayout = () => { [], ); + const ensureAssetUrl = useCallback( + async (documentId, asset, { force = false } = {}) => { + if (!documentId || !asset?.id) { + return null; + } + + try { + const entry = await assetManager.ensureAsset(documentId, asset, { force }); + + if (!entry) { + return null; + } + + setDocuments((prev) => + prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)), + ); + + setSearchResults((prev) => + Array.isArray(prev) + ? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)) + : prev, + ); + + setDocumentDetails((prev) => { + if (!prev.has(documentId)) { + return prev; + } + const current = prev.get(documentId); + const updated = mergeAssetIntoDetail(current, entry); + if (updated === current) { + return prev; + } + const next = new Map(prev); + next.set(documentId, updated); + documentDetailsRef.current = next; + return next; + }); + + return entry; + } catch (error) { + notifyApiError(error, 'Unable to refresh document asset.'); + throw error; + } + }, + [ + assetManager, + setDocuments, + setSearchResults, + setDocumentDetails, + notifyApiError, + ], + ); + const ensurePreviewUrl = useCallback( async (documentId, { force = false } = {}) => { if (!documentId) return null; - const cached = previewCacheRef.current.get(documentId); - const now = Date.now(); - if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) { - return cached; - } - try { - const { data } = await api.get(`/documents/${documentId}/download`); - const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000; - const entry = { - url: data.url, - contentType: data.content_type || null, - filename: data.filename, - expiresAt: now + ttl, - }; - - previewCacheRef.current.set(documentId, entry); - setPreviewCacheTick((prev) => prev + 1); + const entry = await assetManager.ensurePreview(documentId, { force }); + if (entry) { + setPreviewCacheTick((prev) => prev + 1); + } return entry; } catch (error) { - console.error(error); - setStatusMessage('Unable to fetch document preview.', 'error'); + notifyApiError(error, 'Unable to fetch document preview.'); throw error; } }, - [setStatusMessage], + [assetManager, notifyApiError], ); const extractFilesFromDataTransfer = useCallback(async (dataTransfer) => { @@ -3337,8 +3604,8 @@ const AppLayout = () => { try { extracted = await extractFilesFromDataTransfer(dataTransfer); } catch (error) { - console.error(error); - setStatusMessage(error.message || 'Failed to process dropped files.', 'error'); + const message = error.message || 'Failed to process dropped files.'; + notifyApiError(error, message); return; } @@ -3373,7 +3640,7 @@ const AppLayout = () => { await ensureFolderData(targetFolderId, { force: true }); } } catch (error) { - // errors surfaced through setStatusMessage + notifyApiError(error, 'Failed to upload files.'); } finally { setLoading(false); } @@ -3386,6 +3653,7 @@ const AppLayout = () => { refreshCurrentFolder, selectedFolder, ensureFolderData, + notifyApiError, setStatusMessage, ], ); @@ -3425,25 +3693,25 @@ const AppLayout = () => { }); await refreshCurrentFolder(); - if (targetFolderId && targetFolderId !== selectedFolder) { - await ensureFolderData(targetFolderId, { force: true }); - } - } catch (error) { - console.error(error); - const message = error.response?.data?.error || 'Failed to move documents.'; - setStatusMessage(message, 'error'); - } finally { - setLoading(false); + if (targetFolderId && targetFolderId !== selectedFolder) { + await ensureFolderData(targetFolderId, { force: true }); } - }, - [ - api, - ensureFolderData, - refreshCurrentFolder, - selectedFolder, - folderLabelMap, - setStatusMessage, - ], + } catch (error) { + const message = error.response?.data?.error || 'Failed to move documents.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [ + api, + ensureFolderData, + refreshCurrentFolder, + selectedFolder, + folderLabelMap, + notifyApiError, + setStatusMessage, + ], ); const handleBulkMoveFromDetail = useCallback( @@ -3472,15 +3740,14 @@ const AppLayout = () => { setStatusMessage('Document re-analysis queued.', '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'); + notifyApiError(error, message); } finally { setLoading(false); } }, - [token, ensureDocumentDetail, setStatusMessage], + [token, ensureDocumentDetail, notifyApiError, setStatusMessage], ); const openDocumentPreview = useCallback( @@ -3489,20 +3756,60 @@ const AppLayout = () => { setPreviewDocumentId(documentId); setPreviewDocumentLoading(true); try { - await ensureDocumentDetail(documentId, { force: false }); + const detailData = await ensureDocumentDetail(documentId, { force: false }); + + const previewAsset = getAssetFromVersion( + detailData?.document?.current_version || null, + 'preview', + ); + + if (previewAsset) { + const previewExpiresAt = + typeof previewAsset.expiresAt === 'number' ? previewAsset.expiresAt : null; + const previewNeedsRefresh = + !previewAsset.url || (previewExpiresAt && previewExpiresAt <= Date.now()); + if (previewNeedsRefresh) { + try { + await ensureAssetUrl(documentId, previewAsset, { + force: Boolean(previewExpiresAt && previewExpiresAt <= Date.now()), + }); + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + } + } + + const thumbnailAsset = getAssetFromVersion( + detailData?.document?.current_version || null, + 'thumbnail', + ); + + if (thumbnailAsset) { + const thumbExpiresAt = + typeof thumbnailAsset.expiresAt === 'number' ? thumbnailAsset.expiresAt : null; + const thumbNeedsRefresh = + !thumbnailAsset.url || (thumbExpiresAt && thumbExpiresAt <= Date.now()); + if (thumbNeedsRefresh) { + ensureAssetUrl(documentId, thumbnailAsset, { + force: Boolean(thumbExpiresAt && thumbExpiresAt <= Date.now()), + }).catch(() => {}); + } + } + await ensurePreviewUrl(documentId, { force: false }); setActivePreviewId(documentId); if (!skipNavigate && navigate) { navigate(`/documents/${documentId}`, { replace }); } } catch (error) { - console.error(error); + notifyApiError(error, 'Failed to open document preview.'); setPreviewDocumentId(null); } finally { setPreviewDocumentLoading(false); } }, - [ensureDocumentDetail, ensurePreviewUrl, navigate], + [ensureDocumentDetail, ensurePreviewUrl, ensureAssetUrl, navigate, notifyApiError], ); const handleDocumentListFocus = useCallback(() => { @@ -3723,8 +4030,7 @@ const AppLayout = () => { } } catch (error) { if (!cancelled) { - console.error(error); - setStatusMessage('Failed to open document preview.', 'error'); + notifyApiError(error, 'Failed to open document preview.'); } } }; @@ -3743,7 +4049,7 @@ const AppLayout = () => { openDocumentPreview, previewDocumentId, closeDocumentPreview, - setStatusMessage, + notifyApiError, ]); const handleDocumentTitleUpdate = useCallback( @@ -3757,21 +4063,39 @@ const AppLayout = () => { setLoading(true); try { const { data } = await api.patch(`/documents/${documentId}`, { title: trimmed }); + const hydratedDetail = assetManager.hydrateDetail(data); + const hydratedDocument = hydratedDetail?.document || data.document; setDocumentDetails((prev) => { const next = new Map(prev); - next.set(documentId, data); + next.set(documentId, hydratedDetail); documentDetailsRef.current = next; return next; }); setDocuments((prev) => - prev.map((doc) => (doc.id === documentId ? { ...doc, title: trimmed } : doc)), + prev.map((doc) => { + if (doc.id !== documentId) { + return doc; + } + if (hydratedDocument) { + return { ...doc, ...hydratedDocument }; + } + return { ...doc, title: trimmed }; + }), ); setSearchResults((prev) => prev - ? prev.map((doc) => (doc.id === documentId ? { ...doc, title: trimmed } : doc)) + ? prev.map((doc) => { + if (doc.id !== documentId) { + return doc; + } + if (hydratedDocument) { + return { ...doc, ...hydratedDocument }; + } + return { ...doc, title: trimmed }; + }) : null, ); @@ -3800,15 +4124,14 @@ const AppLayout = () => { setStatusMessage('Document title updated.', 'success'); return true; } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to update document title.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); return false; } finally { setLoading(false); } }, - [setStatusMessage, setDocuments, setSearchResults, setFolderContents], + [assetManager, notifyApiError, setStatusMessage, setDocuments, setSearchResults, setFolderContents], ); const handleTagRemove = useCallback( @@ -3822,11 +4145,10 @@ const AppLayout = () => { ensureDocumentDetail(documentId, { force: true }), ]); } catch (error) { - console.error(error); - setStatusMessage('Failed to remove tag.', 'error'); + notifyApiError(error, 'Failed to remove tag.'); } }, - [ensureDocumentDetail, refreshCurrentFolder, refreshTags, setStatusMessage], + [ensureDocumentDetail, refreshCurrentFolder, refreshTags, notifyApiError, setStatusMessage], ); const handleTagAdd = useCallback( @@ -3846,11 +4168,40 @@ const AppLayout = () => { ensureDocumentDetail(document.id, { force: true }), ]); } catch (error) { - console.error(error); - setStatusMessage('Failed to assign tag.', 'error'); + notifyApiError(error, 'Failed to assign tag.'); } }, - [tags, refreshTags, refreshCurrentFolder, ensureDocumentDetail, setStatusMessage], + [ + tags, + refreshTags, + refreshCurrentFolder, + ensureDocumentDetail, + notifyApiError, + setStatusMessage, + ], + ); + + const handleDocumentTagAttach = useCallback( + async ({ documentId, tagId }) => { + if (!documentId || !tagId) { + return false; + } + + try { + await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); + setStatusMessage('Tag assigned.', 'success'); + await Promise.all([ + refreshCurrentFolder(), + ensureDocumentDetail(documentId, { force: true }), + ]); + return true; + } catch (error) { + const message = error.response?.data?.error || 'Failed to assign tag.'; + notifyApiError(error, message); + return false; + } + }, + [api, refreshCurrentFolder, ensureDocumentDetail, notifyApiError, setStatusMessage], ); const handleFolderDelete = useCallback( @@ -3905,17 +4256,25 @@ const AppLayout = () => { } } 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], + setStatusMessage('Folder deleted.', 'success'); + } catch (error) { + const message = error.response?.data?.error || 'Failed to delete folder.'; + notifyApiError(error, message); + } finally { + setLoading(false); + } + }, + [ + token, + ensureFolderData, + selectedFolder, + folderNodes, + applySelectedFolder, + ensureDocumentDetail, + notifyApiError, + setStatusMessage, + ], ); const handleFolderCreate = useCallback( @@ -3960,14 +4319,13 @@ const AppLayout = () => { }); await ensureFolderData(selectedFolder, { force: true }); } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to create folder.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); } finally { setLoading(false); } }, - [token, selectedFolder, ensureFolderData, setStatusMessage], + [token, selectedFolder, ensureFolderData, notifyApiError, setStatusMessage], ); useEffect(() => { @@ -3999,7 +4357,7 @@ const AppLayout = () => { }); if (cancelled) return; - const results = data || []; + const results = assetManager.hydrateDocuments(data || []); setSearchResults(results); if (!results.length) { @@ -4037,8 +4395,7 @@ const AppLayout = () => { } } catch (error) { if (cancelled) return; - console.error(error); - setStatusMessage('Search failed. Please try again.', 'error'); + notifyApiError(error, 'Search failed. Please try again.'); setSearchResults(null); } finally { if (!cancelled && started) { @@ -4061,7 +4418,8 @@ const AppLayout = () => { activeTagFilters, selectedFolder, ensureDocumentDetail, - setStatusMessage, + notifyApiError, + assetManager, ]); useEffect(() => { @@ -4100,16 +4458,21 @@ const AppLayout = () => { await handleFileDrop(event.dataTransfer, selectedFolder); }; - window.addEventListener('dragenter', handleDragEnter); - window.addEventListener('dragover', handleDragOver); - window.addEventListener('dragleave', handleDragLeave); - window.addEventListener('drop', handleDrop); + const dropTarget = shellRef.current; + if (!dropTarget) { + return undefined; + } + + dropTarget.addEventListener('dragenter', handleDragEnter); + dropTarget.addEventListener('dragover', handleDragOver); + dropTarget.addEventListener('dragleave', handleDragLeave); + dropTarget.addEventListener('drop', handleDrop); return () => { - window.removeEventListener('dragenter', handleDragEnter); - window.removeEventListener('dragover', handleDragOver); - window.removeEventListener('dragleave', handleDragLeave); - window.removeEventListener('drop', handleDrop); + dropTarget.removeEventListener('dragenter', handleDragEnter); + dropTarget.removeEventListener('dragover', handleDragOver); + dropTarget.removeEventListener('dragleave', handleDragLeave); + dropTarget.removeEventListener('drop', handleDrop); dragCounterRef.current = 0; setDropOverlayState((prev) => ({ ...prev, active: false })); }; @@ -4131,20 +4494,19 @@ const AppLayout = () => { setLoading(true); appDispatch({ type: 'LOGIN_REQUEST' }); const { data } = await api.post('/auth/login', payload); - appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); - setStatusMessage('Login successful.', 'success'); - } catch (error) { - console.error(error); - appDispatch({ - type: 'LOGIN_FAILURE', - error: error?.response?.data?.error || 'Login failed. Check credentials.', - }); - setStatusMessage('Login failed. Check credentials.', 'error'); - } finally { - setLoading(false); - } - }, - [appDispatch, setStatusMessage], + appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); + setStatusMessage('Login successful.', 'success'); + } catch (error) { + appDispatch({ + type: 'LOGIN_FAILURE', + error: error?.response?.data?.error || 'Login failed. Check credentials.', + }); + notifyApiError(error, 'Login failed. Check credentials.'); + } finally { + setLoading(false); + } + }, + [appDispatch, notifyApiError, setStatusMessage], ); const handleLogout = useCallback(async () => { @@ -4171,14 +4533,13 @@ const AppLayout = () => { 'success', ); } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to queue document re-analysis.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); } finally { setLoading(false); } - }, [setStatusMessage]); + }, [notifyApiError, setStatusMessage]); const folderClickHandlers = { @@ -4188,8 +4549,7 @@ const AppLayout = () => { try { await ensureFolderData(folderId); } catch (error) { - console.error(error); - setStatusMessage('Failed to load folder.', 'error'); + notifyApiError(error, 'Failed to load folder.'); } } setFolderNodes((prev) => { @@ -4411,7 +4771,8 @@ const AppLayout = () => { return changed ? next : prev; }); - previewCacheRef.current.delete(documentId); + assetManager.deleteDocument(documentId); + setPreviewCacheTick((prev) => prev + 1); if (selectedDocumentIds.includes(documentId)) { const remaining = selectedDocumentIds.filter((id) => id !== documentId); @@ -4424,9 +4785,8 @@ const AppLayout = () => { setStatusMessage('Document deleted.', 'success'); } catch (error) { - console.error(error); const message = error.response?.data?.error || 'Failed to delete document.'; - setStatusMessage(message, 'error'); + notifyApiError(error, message); } finally { setLoading(false); } @@ -4441,10 +4801,13 @@ const AppLayout = () => { setSearchResults, setDocumentDetails, setFolderContents, + assetManager, + setPreviewCacheTick, previewDocumentId, closeDocumentPreview, selectedDocumentIds, applySelection, + notifyApiError, ], ); @@ -4588,15 +4951,15 @@ const AppLayout = () => { if (!selectedDocument) { return null; } - return previewCacheRef.current.get(selectedDocument.id) || null; - }, [selectedDocument, previewCacheTick]); + return assetManager.getPreview(selectedDocument.id); + }, [assetManager, selectedDocument, previewCacheTick]); const previewWorkspaceEntry = useMemo(() => { if (!previewDocumentId) { return null; } - return previewCacheRef.current.get(previewDocumentId) || null; - }, [previewDocumentId, previewCacheTick]); + return assetManager.getPreview(previewDocumentId); + }, [assetManager, previewDocumentId, previewCacheTick]); const previewWorkspaceDetail = previewDocumentId ? documentDetails.get(previewDocumentId) @@ -4628,10 +4991,35 @@ const AppLayout = () => { tags, }; + const resolveThumbnailUrlForDoc = useCallback( + (doc) => { + if (!doc) { + return null; + } + const asset = getDocumentAsset(doc, 'thumbnail'); + const now = Date.now(); + const assetExpiresAt = typeof asset?.expiresAt === 'number' ? asset.expiresAt : null; + if (asset?.url && (!assetExpiresAt || assetExpiresAt > now)) { + return asset.url; + } + if (doc.id && asset?.id) { + ensureAssetUrl(doc.id, asset, { force: Boolean(assetExpiresAt && assetExpiresAt <= now) }).catch( + () => {}, + ); + } + return null; + }, + [ensureAssetUrl, getDocumentAsset], + ); + + const showSkeuoWorkspace = useCallback(() => setWorkspaceMode('skeuo'), [setWorkspaceMode]); + const exitSkeuoWorkspace = useCallback(() => setWorkspaceMode('table'), [setWorkspaceMode]); + const documentsTableProps = { currentFolderName, breadcrumbs, onRefresh: refreshCurrentFolder, + onShowSkeuoWorkspace: showSkeuoWorkspace, subfolders: currentSubfolders, documents, searchResults, @@ -4658,6 +5046,8 @@ const AppLayout = () => { onDocumentListFocus: handleDocumentListFocus, onDocumentListKeyDown: handleDocumentListKeyDown, onFocusedRowChange: setFocusedRowKey, + ensureAssetUrl, + getDocumentAsset, }; const detailPanelProps = { @@ -4680,8 +5070,47 @@ const AppLayout = () => { onPromoteSelection: promoteSelectionOrder, activePreviewId, onUpdateTitle: handleDocumentTitleUpdate, + ensureAssetUrl, + getDocumentAsset, }; + const skeuoWorkspaceProps = useMemo( + () => ({ + documents, + searchResults, + breadcrumbs, + currentFolderName, + onExit: exitSkeuoWorkspace, + onRefresh: refreshCurrentFolder, + onDocumentOpen: openDocumentPreview, + resolveThumbnailUrl: resolveThumbnailUrlForDoc, + availableTags: tags, + onCreateTag: handleTagCreate, + onAssignTagToDocument: handleDocumentTagAttach, + onRemoveTagFromDocument: handleTagRemove, + resolveAssetPath: resolveApiPath, + ensureAssetUrl, + getDocumentAsset, + }), + [ + documents, + searchResults, + breadcrumbs, + currentFolderName, + exitSkeuoWorkspace, + refreshCurrentFolder, + openDocumentPreview, + resolveThumbnailUrlForDoc, + tags, + handleTagCreate, + handleDocumentTagAttach, + handleTagRemove, + resolveApiPath, + ensureAssetUrl, + getDocumentAsset, + ], + ); + const contextValue = useMemo( () => ({ token, @@ -4694,6 +5123,8 @@ const AppLayout = () => { tags, refreshTags, handleTagUpdate, + handleTagCreate, + handleDocumentTagAttach, previewActive, previewWorkspaceDocument, previewWorkspaceDetail, @@ -4702,6 +5133,10 @@ const AppLayout = () => { handleThumbnailRegeneration, documentsTableProps, detailPanelProps, + workspaceMode, + showSkeuoWorkspace, + exitSkeuoWorkspace, + skeuoWorkspaceProps, }), [ token, @@ -4714,6 +5149,8 @@ const AppLayout = () => { tags, refreshTags, handleTagUpdate, + handleTagCreate, + handleDocumentTagAttach, previewActive, previewWorkspaceDocument, previewWorkspaceDetail, @@ -4722,6 +5159,10 @@ const AppLayout = () => { handleThumbnailRegeneration, documentsTableProps, detailPanelProps, + workspaceMode, + showSkeuoWorkspace, + exitSkeuoWorkspace, + skeuoWorkspaceProps, ], ); @@ -4737,7 +5178,7 @@ const AppLayout = () => { return ( -
+
{ handleThumbnailRegeneration, documentsTableProps, detailPanelProps, + workspaceMode, + skeuoWorkspaceProps, } = useAppShell(); if (previewActive && previewWorkspaceDocument) { @@ -4814,6 +5257,14 @@ const DocumentsRoute = () => { ); } + if (workspaceMode === 'skeuo') { + return ( +
+ +
+ ); + } + return ( @@ -4831,6 +5282,18 @@ const LoginRoute = () => { const setStatusMessage = useCallback((message, variant = 'info') => { setStatus(message ? { message, variant } : null); }, []); + const handleLoginApiReport = useCallback( + ({ message, variant }) => setStatusMessage(message, variant), + [setStatusMessage], + ); + const reportLoginError = useApiError({ + onReport: handleLoginApiReport, + }); + const notifyLoginError = useCallback( + (error, fallbackMessage, variant = 'error') => + reportLoginError(error, { message: fallbackMessage, variant }), + [reportLoginError], + ); const handleLogin = useCallback( async (event) => { @@ -4852,13 +5315,12 @@ const LoginRoute = () => { appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token }); setStatusMessage('Login successful.', 'success'); } catch (error) { - console.error(error); const message = error?.response?.data?.error || 'Login failed. Check credentials.'; appDispatch({ type: 'LOGIN_FAILURE', error: message }); - setStatusMessage(message, 'error'); + notifyLoginError(error, message); } }, - [appDispatch, setStatusMessage], + [appDispatch, notifyLoginError, setStatusMessage], ); const redirectTarget = useMemo(() => { diff --git a/frontend/src/skeuomorphic_ws.css b/frontend/src/skeuomorphic_ws.css new file mode 100644 index 0000000..954eeb6 --- /dev/null +++ b/frontend/src/skeuomorphic_ws.css @@ -0,0 +1,317 @@ +/* Skeuomorphic workspace styles */ +.skeuo-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.skeuo-shell { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + --desk-1: #f0e2c6; + --desk-2: #e7d2ad; + --desk-3: #d8b98a; + --desk-grain-dark: rgba(110, 86, 48, 0.08); + --desk-grain-fine: rgba(86, 66, 33, 0.04); + --desk-vignette: rgba(60, 40, 18, 0.15); + background: + radial-gradient(120% 85% at 50% -10%, var(--desk-vignette), #0000 48%), + linear-gradient(7deg, #0000 0 32%, var(--desk-grain-dark) 60%, #0000 85%), + repeating-linear-gradient( + 7deg, + var(--desk-grain-fine) 0 2px, + #0000 2px 9px + ), + linear-gradient(90deg, var(--desk-1), var(--desk-2) 48%, var(--desk-3) 100%); + background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%; + background-blend-mode: multiply, overlay, normal, normal; + position: relative; +} + +.skeuo-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.5rem 0.75rem; +} + +.skeuo-header__meta { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.skeuo-header__meta h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.skeuo-breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--muted); +} + +.skeuo-crumb { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.skeuo-crumb.is-current { + color: var(--fg); + font-weight: 600; +} + +.skeuo-crumb-separator { + opacity: 0.4; +} + +.skeuo-header__actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.skeuo-canvas { + flex: 1; + position: relative; + overflow: hidden; + margin: 0 1.5rem 1.5rem; + border-radius: 18px; + background-image: radial-gradient(rgba(255, 255, 255, 0.5) 9%, transparent 9%), + linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent 32%); + background-size: 46px 46px, 100% 100%; + box-shadow: inset 0 16px 28px rgba(0, 0, 0, 0.07), inset 0 -6px 20px rgba(0, 0, 0, 0.05); + backdrop-filter: blur(6px); +} + +.skeuo-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: var(--muted); + font-size: 0.95rem; +} + +.skeuo-item { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + width: auto; + cursor: grab; + touch-action: none; + transform-origin: center center; + transition: transform 0.28s ease, box-shadow 0.16s ease; + outline: none; + will-change: transform; +} + +.skeuo-item__body { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + transition: width 0.28s ease, height 0.28s ease; +} + +.skeuo-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} + +.skeuo-item.is-dragging { + cursor: grabbing; + transition: none; +} + +.skeuo-item.is-zoomed { + cursor: pointer; +} + +.skeuo-item.is-zoomed .skeuo-item__card { + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24); +} + +.skeuo-item.is-tag-target .skeuo-item__card { + outline: 1em dashed var(--accent); + outline-offset: 1.41em; +} + +.skeuo-item.is-tag-pending .skeuo-item__card { + opacity: 0.6; +} + +.skeuo-item.is-filtered-out { + opacity: 0.12; + pointer-events: none; + filter: blur(15px) grayscale(100%); + transition: opacity 0.6s ease, filter 0.28s ease; + z-index: 0 !important; +} + +.skeuo-item__tags { + --tag-scale: 1; + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-end; + transform-origin: top right; + transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em); + transition: transform 0.28s ease; +} + +.skeuo-tag { + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding: 0.28rem 0.7rem; + border-radius: 1px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface); + color: var(--fg); + font-size: 1rem; + font-weight: 600; + box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18); + text-align: left; + white-space: nowrap; + user-select: none; +} + +.skeuo-item__tags .skeuo-tag { + font-size: 0.75rem; + padding: 0.18rem 0.55rem; + pointer-events: auto; + cursor: grab; + transition: transform 0.16s ease, opacity 0.2s ease; +} + +.skeuo-tag.is-drag-hidden { + opacity: 0.4; + pointer-events: none; +} + +.skeuo-tag span { + display: block; + pointer-events: none; + user-select: none; +} + +.skeuo-item__tags .skeuo-tag.is-tear-pending { + opacity: 0.4; +} + +.skeuo-tag-shelf { + position: absolute; + top: 2.25rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + align-items: flex-end; + max-height: calc(100% - 4rem); + overflow: auto; + padding: 0; + z-index: 20; +} + +.skeuo-tag-shelf .skeuo-tag { + cursor: grab; +} + +.skeuo-tag-shelf .skeuo-tag:active { + cursor: grabbing; +} + +.skeuo-tag-shelf .skeuo-tag.is-inactive { + opacity: 0.25; +} + +.skeuo-tag-shelf .skeuo-tag.is-active { + opacity: 1; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08); +} + +.skeuo-tag-add { + width: 2.4rem; + height: 2.4rem; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.15); + background: rgba(255, 255, 255, 0.8); + color: var(--fg); + font-size: 1.4rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin-top: 0.4rem; + transition: background 0.2s ease, transform 0.2s ease; +} + +.skeuo-tag-add:hover { + background: rgba(255, 255, 255, 1); + transform: translateY(-1px); +} + +.skeuo-tag-add:active { + transform: translateY(0); +} + +.skeuo-tag-add:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +body.skeuo-cursor-remove, +body.skeuo-cursor-remove * { + cursor: not-allowed !important; +} + +.skeuo-item__shadow { + display: none; +} + +.skeuo-item__card { + position: relative; + width: 100%; + height: 100%; + border-radius: 0; + background: var(--surface); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24); + overflow: hidden; +} + +.skeuo-item__card img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.skeuo-item__placeholder { + font-size: 0.95rem; + font-weight: 500; + letter-spacing: normal; + color: var(--muted); +} diff --git a/frontend/src/skeuomorphic_ws.jsx b/frontend/src/skeuomorphic_ws.jsx new file mode 100644 index 0000000..9f4ff7e --- /dev/null +++ b/frontend/src/skeuomorphic_ws.jsx @@ -0,0 +1,1639 @@ +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import './skeuomorphic_ws.css'; + +const ITEM_WIDTH = 220; +const ITEM_HEIGHT = 260; +const CANVAS_PADDING = 24; +const ROTATION_RANGE = 7; +const JITTER_X = 26; +const JITTER_Y = 32; +const DEFAULT_CANVAS_WIDTH = 1024; +const DEFAULT_CANVAS_HEIGHT = 680; +const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; + +const resolveSizeKey = (doc) => + doc?.id || doc?.document_id || doc?.uuid || doc?.original_name || doc?.title || 'doc'; + +const CARD_WIDTH_MIN = 200; +const CARD_WIDTH_MAX = 260; +const CARD_HEIGHT_MIN = 240; +const CARD_HEIGHT_MAX = 340; +const CARD_ASPECT_MIN = 0.8; // height / width +const CARD_ASPECT_MAX = 1.35; +const ZOOM_FILL_RATIO = 0.97; +const ZOOM_MIN_SCALE = 1.05; +const ZOOM_MAX_SCALE = 5; +const TAG_REMOVE_DISTANCE = 160; + +const DRAG_PREVIEW_KEY = Symbol('dragPreview'); +const DEBUG_DRAG = false; +const DEBUG_FOCUS = true; +const DEBUG_DROP = true; + +const resolveTagKey = (tag) => { + if (!tag) { + return null; + } + const key = tag.id ?? tag.uuid ?? tag.slug ?? tag.label; + return key != null ? String(key) : null; +}; + +const createDragPreview = (node, clientX, clientY) => { + if (!(node instanceof HTMLElement)) { + return null; + } + const rect = node.getBoundingClientRect(); + const safeClientX = Number.isFinite(clientX) ? clientX : rect.left + rect.width / 2; + const safeClientY = Number.isFinite(clientY) ? clientY : rect.top + rect.height / 2; + const offsetX = clamp(safeClientX - rect.left, 0, rect.width); + const offsetY = clamp(safeClientY - rect.top, 0, rect.height); + const clone = node.cloneNode(true); + clone.style.position = 'absolute'; + clone.style.top = '-9999px'; + clone.style.left = '-9999px'; + clone.style.pointerEvents = 'none'; + clone.style.opacity = '1'; + clone.style.transform = 'none'; + document.body.appendChild(clone); + return { clone, offsetX, offsetY }; +}; + +const cleanupPreview = (previewNode) => { + if (previewNode && previewNode.parentNode) { + previewNode.parentNode.removeChild(previewNode); + } +}; + +const useDocumentDrag = ({ + layoutRef, + itemRefs, + documentLookup, + ensureDocumentSize, + resolveBaseMetrics, + bringToFront, + setDraggingId, + syncLayoutSnapshot, + canvasSize, + toggleZoom, +}) => { + const dragStateRef = useRef(null); + + const finishDrag = useCallback( + (pointerId) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== pointerId) { + return; + } + dragStateRef.current = null; + setDraggingId((current) => (current === state.docId ? null : current)); + syncLayoutSnapshot(); + }, + [setDraggingId, syncLayoutSnapshot], + ); + + const handlePointerDown = useCallback( + (event, docId, { lockWhenZoomed = false } = {}) => { + if (DEBUG_DRAG) { + console.log( + '[skeuo] handlePointerDown fired for doc', + docId, + 'button', + event.button, + 'pointerType', + event.pointerType, + 'pointerId', + event.pointerId, + ); + } + event.preventDefault(); + const entry = layoutRef.current.get(docId) || null; + const doc = documentLookup.get(docId) || null; + const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); + const { baseScale } = resolveBaseMetrics(doc, docWidth, docHeight); + const defaultCenterX = CANVAS_PADDING + docWidth / 2; + const defaultCenterY = CANVAS_PADDING + docHeight / 2; + const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; + const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; + + if (entry && (entry.centerX !== centerX || entry.centerY !== centerY)) { + layoutRef.current.set(docId, { ...entry, centerX, centerY }); + } + + bringToFront(docId); + dragStateRef.current = { + docId, + pointerId: event.pointerId, + originCenterX: centerX, + originCenterY: centerY, + startX: event.clientX, + startY: event.clientY, + rotation: entry?.rotation ?? 0, + moved: false, + locked: Boolean(lockWhenZoomed), + width: docWidth, + height: docHeight, + scale: baseScale, + }; + setDraggingId(docId); + }, + [bringToFront, documentLookup, ensureDocumentSize, layoutRef, resolveBaseMetrics, setDraggingId], + ); + + const handlePointerMove = useCallback( + (event) => { + const state = dragStateRef.current; + if (!state) { + if (DEBUG_DRAG) { + console.log('[skeuo] handlePointerMove: no drag state for pointer', event.pointerId); + } + return; + } + if (state.pointerId !== event.pointerId) { + if (DEBUG_DRAG) { + console.log( + '[skeuo] handlePointerMove: pointer mismatch expected', + state.pointerId, + 'got', + event.pointerId, + ); + } + return; + } + event.preventDefault(); + if (state.locked) { + if (DEBUG_DRAG) { + console.log('[skeuo] handlePointerMove: locked drag for doc', state.docId); + } + return; + } + + const entry = layoutRef.current.get(state.docId); + if (!entry) { + return; + } + + const deltaX = event.clientX - state.startX; + const deltaY = event.clientY - state.startY; + const nextCenterX = state.originCenterX + deltaX; + const nextCenterY = state.originCenterY + deltaY; + + const docWidth = state.width; + const docHeight = state.height; + const halfWidth = docWidth / 2; + const halfHeight = docHeight / 2; + const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; + const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; + const minCenterX = CANVAS_PADDING + halfWidth; + const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth); + const minCenterY = CANVAS_PADDING + halfHeight; + const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight); + const clampedCenterX = clamp(nextCenterX, minCenterX, maxCenterX); + const clampedCenterY = clamp(nextCenterY, minCenterY, maxCenterY); + + const prevCenterX = typeof entry.centerX === 'number' ? entry.centerX : state.originCenterX; + const prevCenterY = typeof entry.centerY === 'number' ? entry.centerY : state.originCenterY; + if (Math.abs(clampedCenterX - prevCenterX) < 0.5 && Math.abs(clampedCenterY - prevCenterY) < 0.5) { + if (DEBUG_DRAG) { + console.log('[skeuo] handlePointerMove: movement under threshold for doc', state.docId); + } + return; + } + + const updated = { ...entry, centerX: clampedCenterX, centerY: clampedCenterY }; + layoutRef.current.set(state.docId, updated); + + const node = itemRefs.current.get(state.docId); + if (node) { + node.style.transform = formatTransform( + clampedCenterX, + clampedCenterY, + state.rotation, + state.scale || 1, + ); + } + state.moved = true; + if (DEBUG_DRAG) { + console.log('[skeuo] handlePointerMove: moved doc', state.docId, 'to', clampedCenterX, clampedCenterY); + } + }, + [canvasSize.height, canvasSize.width, itemRefs, layoutRef], + ); + + const handlePointerUp = useCallback( + (event) => { + const state = dragStateRef.current; + if (state && state.pointerId === event.pointerId) { + const moved = Boolean(state.moved); + const docId = state.docId; + finishDrag(event.pointerId); + if (!moved) { + toggleZoom(docId); + } + return; + } + finishDrag(event.pointerId); + }, + [finishDrag, toggleZoom], + ); + + const handlePointerCancel = useCallback( + (event) => { + finishDrag(event.pointerId); + }, + [finishDrag], + ); + + return { + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + }; +}; + +const resolvePreviewDimensions = (doc) => { + if (!doc) return null; + + const group = doc?.current_version?.assets || doc?.assets; + let asset = null; + if (group && !Array.isArray(group) && group?.preview) { + asset = group.preview; + } else if (Array.isArray(group)) { + asset = group.find((entry) => entry?.asset_type === 'preview') || null; + } + + if (!asset) { + return null; + } + + const width = asset?.metadata?.width; + const height = asset?.metadata?.height; + + if (Number.isFinite(width) && Number.isFinite(height)) { + return { width, height }; + } + + return null; +}; + +const seededRandom = (input) => { + const text = String(input); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0) / 4294967295; +}; + +const randomRangeFromSeed = (seedKey, min, max) => { + const span = max - min; + if (span <= 0) return min; + const seed = seededRandom(seedKey); + return min + seed * span; +}; + +const buildKey = (docId, suffix) => `${docId}::${suffix}`; + +const clamp = (value, min, max) => { + if (value < min) return min; + if (value > max) return max; + return value; +}; + +const formatTransform = (x, y, rotation = 0, scale = 1) => + `translate3d(${x}px, ${y}px, 0) rotate(${rotation}deg) scale(${scale})`; + +const normalizeColor = (input) => { + if (!input) return null; + const value = String(input).trim(); + if (!value) return null; + if (/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value)) { + return value.length === 4 + ? `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}` + : value; + } + if (/^[0-9a-fA-F]{6}$/.test(value)) { + return `#${value}`; + } + return null; +}; + +const getContrastingTextColor = (hex) => { + if (!hex) return '#1b1f24'; + const value = parseInt(hex.slice(1), 16); + const r = (value >> 16) & 0xff; + const g = (value >> 8) & 0xff; + const b = value & 0xff; + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.6 ? '#1b1f24' : '#ffffff'; +}; + +const absolutizeAssetUrl = (input, resolver) => { + if (!input) return null; + if (/^(?:https?:|data:|blob:)/i.test(input)) { + return input; + } + if (typeof resolver === 'function') { + return resolver(input); + } + return input; +}; + +const resolvePreviewUrl = (doc, resolver) => { + if (!doc) return null; + const group = doc?.current_version?.assets || doc?.assets; + let asset = null; + if (group && !Array.isArray(group) && group?.preview) { + asset = group.preview; + } else if (Array.isArray(group)) { + asset = group.find((entry) => entry?.asset_type === 'preview') || null; + } + if (!asset?.url) { + return null; + } + const now = Date.now(); + const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; + if (expiresAt && expiresAt <= now) { + return null; + } + return absolutizeAssetUrl(asset.url, resolver); +}; + +const SkeuomorphicWorkspace = ({ + documents = [], + searchResults = null, + breadcrumbs = [], + currentFolderName = 'Folder', + onExit, + onRefresh, + onDocumentOpen, + resolveThumbnailUrl, + availableTags = [], + onCreateTag = null, + onAssignTagToDocument = null, + onRemoveTagFromDocument = null, + resolveAssetPath = null, + ensureAssetUrl = null, + getDocumentAsset = () => null, +}) => { + const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); + const showingSearchResults = searchResults !== null; + + const containerRef = useRef(null); + const layoutRef = useRef(new Map()); + const itemRefs = useRef(new Map()); + const zCounterRef = useRef(10); + + const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); + + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + const [draggingId, setDraggingId] = useState(null); + const [zoomedId, setZoomedId] = useState(null); + const [tagDropTargetId, setTagDropTargetId] = useState(null); + const [pendingTagDocId, setPendingTagDocId] = useState(null); + const [pendingRemovalTag, setPendingRemovalTag] = useState(null); + const [activeShelfTagId, setActiveShelfTagId] = useState(null); + const draggingTagRef = useRef(null); + const pendingDocTagDragRef = useRef(null); + const docSizeMapRef = useRef(new Map()); + const removalCursorActiveRef = useRef(false); + + useEffect(() => { + if (!ensureAssetUrl) { + return; + } + + const now = Date.now(); + const seen = new Set(); + const queueAsset = (docId, asset) => { + if (!docId || !asset?.id || seen.has(asset.id)) { + return; + } + const type = asset.asset_type; + if (type && type !== 'thumbnail' && type !== 'preview') { + return; + } + const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null; + const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now); + if (hasFreshUrl) { + return; + } + const force = Boolean(asset.url && expiresAt && expiresAt <= now); + seen.add(asset.id); + ensureAssetUrl(docId, asset, { force }).catch(() => {}); + }; + + items.forEach((doc) => { + const docId = doc?.id; + if (!docId) { + return; + } + + const previewAsset = getDocumentAsset(doc, 'preview'); + if (previewAsset) { + queueAsset(docId, previewAsset); + } + const thumbnailAsset = getDocumentAsset(doc, 'thumbnail'); + if (thumbnailAsset) { + queueAsset(docId, thumbnailAsset); + } + }); + }, [items, ensureAssetUrl]); + + const focusCanvas = useCallback(() => { + const canvas = containerRef.current; + if (canvas && typeof canvas.focus === 'function') { + const focusTarget = () => { + try { + if (DEBUG_FOCUS) { + console.log('[skeuo] focusCanvas -> attempting focus', canvas); + } + canvas.focus({ preventScroll: true }); + if (DEBUG_FOCUS) { + console.log('[skeuo] focusCanvas: applied focus. activeElement:', document?.activeElement); + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + }; + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(focusTarget); + } else { + setTimeout(focusTarget, 0); + } + if (DEBUG_FOCUS) { + console.log('[skeuo] focusCanvas: scheduling focus. activeElement:', document?.activeElement); + } + } + }, []); + + const queueFocusCanvas = useCallback(() => { + if (typeof window === 'undefined') { + focusCanvas(); + return; + } + if (DEBUG_FOCUS) { + console.log('[skeuo] queueFocusCanvas -> scheduling deferred focus'); + } + setTimeout(() => { + if (DEBUG_FOCUS) { + console.log('[skeuo] queueFocusCanvas -> executing deferred focus'); + } + focusCanvas(); + }, 0); + }, [focusCanvas]); + + const updateRemovalCursor = useCallback((active) => { + if (typeof document === 'undefined') { + return; + } + if (removalCursorActiveRef.current === active) { + return; + } + const body = document.body; + if (!body) { + return; + } + removalCursorActiveRef.current = active; + if (active) { + body.classList.add('skeuo-cursor-remove'); + } else { + body.classList.remove('skeuo-cursor-remove'); + } + }, []); + + useEffect( + () => () => { + updateRemovalCursor(false); + }, + [updateRemovalCursor], + ); + + const isTagTransfer = useCallback((event) => { + const types = event.dataTransfer?.types; + if (!types) return false; + return TAG_MIME_TYPES.some((type) => + typeof types.includes === 'function' + ? types.includes(type) + : Array.from(types).includes(type), + ); + }, []); + + const handleShelfTagDragStart = useCallback((event, tag) => { + if (!tag) return; + try { + event.dataTransfer.effectAllowed = 'copy'; + const payload = JSON.stringify({ id: tag.id, label: tag.label }); + event.dataTransfer.setData('application/x-papercrate-tag', payload); + event.dataTransfer.setData('text/papercrate-tag', payload); + event.dataTransfer.setData('text/plain', tag.label || 'Tag'); + } catch (error) { + console.warn('Failed to initiate tag drag', error); + } + const node = event.currentTarget; + const hideNode = () => { + if (node instanceof HTMLElement) { + node.classList.add('is-drag-hidden'); + } + }; + if (node instanceof HTMLElement) { + if (node[DRAG_PREVIEW_KEY]) { + cleanupPreview(node[DRAG_PREVIEW_KEY]); + delete node[DRAG_PREVIEW_KEY]; + } + const preview = createDragPreview(node, event.clientX, event.clientY); + if (preview && event.dataTransfer) { + try { + event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); + node[DRAG_PREVIEW_KEY] = preview.clone; + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + } + } + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(hideNode); + } else { + setTimeout(hideNode, 0); + } + }, []); + + const handleTagDragEnd = useCallback(() => { + updateRemovalCursor(false); + setTagDropTargetId(null); + }, [updateRemovalCursor]); + + const handleShelfTagDragEndWithReset = useCallback((event) => { + if (event?.currentTarget instanceof HTMLElement) { + const target = event.currentTarget; + const showNode = () => { + if (target instanceof HTMLElement) { + target.classList.remove('is-drag-hidden'); + const stored = target[DRAG_PREVIEW_KEY]; + cleanupPreview(stored); + delete target[DRAG_PREVIEW_KEY]; + } + }; + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(showNode); + } else { + setTimeout(showNode, 0); + } + } + handleTagDragEnd(); + queueFocusCanvas(); + }, [handleTagDragEnd, queueFocusCanvas]); + + const ensureDocumentSize = useCallback((doc) => { + const key = resolveSizeKey(doc); + const cache = docSizeMapRef.current.get(key); + if (cache) { + return cache; + } + let width; + let height; + + const intrinsic = resolvePreviewDimensions(doc); + if (intrinsic?.width && intrinsic?.height) { + const ratio = intrinsic.height / intrinsic.width || 1; + width = intrinsic.width; + height = intrinsic.height; + + // scale down to fit within max bounds + if (width > CARD_WIDTH_MAX || height > CARD_HEIGHT_MAX) { + const scale = Math.min(CARD_WIDTH_MAX / width, CARD_HEIGHT_MAX / height); + width *= scale; + height *= scale; + } + + // scale up to satisfy minimum bounds while respecting ratio + if (width < CARD_WIDTH_MIN || height < CARD_HEIGHT_MIN) { + const scale = Math.max(CARD_WIDTH_MIN / width, CARD_HEIGHT_MIN / height); + width *= scale; + height *= scale; + if (width > CARD_WIDTH_MAX || height > CARD_HEIGHT_MAX) { + const downScale = Math.min(CARD_WIDTH_MAX / width, CARD_HEIGHT_MAX / height); + width *= downScale; + height *= downScale; + } + } + + width = clamp(width, CARD_WIDTH_MIN, CARD_WIDTH_MAX); + height = clamp(height, CARD_HEIGHT_MIN, CARD_HEIGHT_MAX); + } else { + const widthSeed = seededRandom(`${key}:width`); + const ratioSeed = seededRandom(`${key}:ratio`); + const ratio = CARD_ASPECT_MIN + ratioSeed * (CARD_ASPECT_MAX - CARD_ASPECT_MIN); + width = CARD_WIDTH_MIN + widthSeed * (CARD_WIDTH_MAX - CARD_WIDTH_MIN); + height = width * ratio; + + if (width > CARD_WIDTH_MAX || height > CARD_HEIGHT_MAX) { + const scale = Math.min(CARD_WIDTH_MAX / width, CARD_HEIGHT_MAX / height); + width *= scale; + height *= scale; + } + + if (width < CARD_WIDTH_MIN || height < CARD_HEIGHT_MIN) { + const scale = Math.max(CARD_WIDTH_MIN / width, CARD_HEIGHT_MIN / height); + width *= scale; + height *= scale; + } + + width = clamp(width, CARD_WIDTH_MIN, CARD_WIDTH_MAX); + height = clamp(height, CARD_HEIGHT_MIN, CARD_HEIGHT_MAX); + } + + const size = { width, height }; + docSizeMapRef.current.set(key, size); + return size; + }, []); + + const documentLookup = useMemo(() => { + const map = new Map(); + items.forEach((doc) => { + if (doc?.id) { + map.set(doc.id, doc); + } + }); + return map; + }, [items]); + + useEffect(() => { + docSizeMapRef.current = new Map(); + }, [items]); + + useEffect(() => { + if (!activeShelfTagId) { + return; + } + const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId); + if (!stillExists) { + setActiveShelfTagId(null); + } + }, [activeShelfTagId, availableTags]); + + +const resolveZoomMetrics = useCallback( + (doc, cardWidth, cardHeight) => { + const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; + const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; + const safeCardWidth = cardWidth || CARD_WIDTH_MIN; + const safeCardHeight = cardHeight || CARD_HEIGHT_MIN; + const usableWidth = Math.max(canvasWidth - CANVAS_PADDING * 2, safeCardWidth); + const usableHeight = Math.max(canvasHeight - CANVAS_PADDING * 2, safeCardHeight); + const viewportTargetWidth = usableWidth * ZOOM_FILL_RATIO; + const viewportTargetHeight = usableHeight * ZOOM_FILL_RATIO; + + let zoomWidth = safeCardWidth; + let zoomHeight = safeCardHeight; + + const previewDims = doc ? resolvePreviewDimensions(doc) : null; + if (previewDims?.width && previewDims?.height) { + const previewWidth = previewDims.width; + const previewHeight = previewDims.height; + const widthScaleLimit = previewWidth > 0 ? viewportTargetWidth / previewWidth : 1; + const heightScaleLimit = previewHeight > 0 ? viewportTargetHeight / previewHeight : 1; + const scaleToFit = Math.min(1, widthScaleLimit || 1, heightScaleLimit || 1); + zoomWidth = previewWidth * scaleToFit; + zoomHeight = previewHeight * scaleToFit; + } else { + const rawScale = Math.min( + viewportTargetWidth / safeCardWidth, + viewportTargetHeight / safeCardHeight, + ); + const boundedScale = + rawScale >= 1 + ? clamp(Math.max(rawScale, ZOOM_MIN_SCALE), ZOOM_MIN_SCALE, ZOOM_MAX_SCALE) + : rawScale; + zoomWidth = safeCardWidth * boundedScale; + zoomHeight = safeCardHeight * boundedScale; + } + + if (!Number.isFinite(zoomWidth) || zoomWidth <= 0) { + zoomWidth = safeCardWidth; + } + if (!Number.isFinite(zoomHeight) || zoomHeight <= 0) { + zoomHeight = safeCardHeight; + } + + zoomWidth = Math.max(zoomWidth, safeCardWidth); + zoomHeight = Math.max(zoomHeight, safeCardHeight); + + const zoomTargetX = (canvasWidth - zoomWidth) / 2; + const zoomTargetY = (canvasHeight - zoomHeight) / 2; + const maxX = Math.max(CANVAS_PADDING, canvasWidth - zoomWidth - CANVAS_PADDING); + const maxY = Math.max(CANVAS_PADDING, canvasHeight - zoomHeight - CANVAS_PADDING); + const clampedX = clamp(zoomTargetX, CANVAS_PADDING, maxX); + const clampedY = clamp(zoomTargetY, CANVAS_PADDING, maxY); + const zoomCenterX = clampedX + zoomWidth / 2; + const zoomCenterY = clampedY + zoomHeight / 2; + + return { + zoomWidth, + zoomHeight, + zoomCenterX, + zoomCenterY, + }; + }, + [canvasSize.width, canvasSize.height], + ); + + const resolveBaseMetrics = useCallback( + (doc, cardWidth, cardHeight) => { + const previewDims = doc ? resolvePreviewDimensions(doc) : null; + if (previewDims?.width && previewDims?.height) { + const baseWidth = Math.max(previewDims.width, cardWidth); + const baseHeight = Math.max(previewDims.height, cardHeight); + const scaleX = cardWidth / baseWidth; + const scaleY = cardHeight / baseHeight; + const baseScale = Math.min(scaleX, scaleY, 1); + return { + baseWidth, + baseHeight, + baseScale: Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1, + }; + } + return { + baseWidth: cardWidth, + baseHeight: cardHeight, + baseScale: 1, + }; + }, + [], + ); + + const syncLayoutSnapshot = useCallback(() => { + setLayoutSnapshot(new Map(layoutRef.current)); + }, []); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return () => {}; + + const commitSize = () => { + const rect = container.getBoundingClientRect(); + const width = Math.floor(rect.width) || 0; + const height = Math.floor(rect.height) || 0; + setCanvasSize((prev) => { + if (prev.width === width && prev.height === height) { + return prev; + } + return { width, height }; + }); + }; + + commitSize(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', commitSize); + return () => { + window.removeEventListener('resize', commitSize); + }; + } + + const observer = new ResizeObserver(() => { + commitSize(); + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + useEffect(() => { + if (!items.length) { + layoutRef.current = new Map(); + syncLayoutSnapshot(); + return; + } + + const previous = layoutRef.current; + const next = new Map(); + let maxZ = zCounterRef.current; + const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; + const canvasHeight = canvasSize.height || DEFAULT_CANVAS_HEIGHT; + + items.forEach((doc) => { + const { width: docWidth, height: docHeight } = ensureDocumentSize(doc); + const halfWidth = docWidth / 2; + const halfHeight = docHeight / 2; + + const minCenterX = CANVAS_PADDING + halfWidth; + const maxCenterX = Math.max(minCenterX, canvasWidth - CANVAS_PADDING - halfWidth); + const minCenterY = CANVAS_PADDING + halfHeight; + const maxCenterY = Math.max(minCenterY, canvasHeight - CANVAS_PADDING - halfHeight); + + const existing = previous.get(doc.id); + if (existing) { + const defaultCenterX = (minCenterX + maxCenterX) / 2; + const defaultCenterY = (minCenterY + maxCenterY) / 2; + const prevCenterX = + typeof existing.centerX === 'number' ? existing.centerX : defaultCenterX; + const prevCenterY = + typeof existing.centerY === 'number' ? existing.centerY : defaultCenterY; + const centerX = clamp(prevCenterX, minCenterX, maxCenterX); + const centerY = clamp(prevCenterY, minCenterY, maxCenterY); + const rotation = existing.rotation ?? 0; + const z = existing.z ?? maxZ; + maxZ = Math.max(maxZ, z); + next.set(doc.id, { centerX, centerY, rotation, z }); + return; + } + + const seedX = seededRandom(`${resolveSizeKey(doc)}:centerX`); + const seedY = seededRandom(`${resolveSizeKey(doc)}:centerY`); + const rangeX = Math.max(maxCenterX - minCenterX, 0); + const rangeY = Math.max(maxCenterY - minCenterY, 0); + const centerX = + rangeX > 0 ? minCenterX + seedX * rangeX : (minCenterX + maxCenterX) / 2; + const centerY = + rangeY > 0 ? minCenterY + seedY * rangeY : (minCenterY + maxCenterY) / 2; + const rotation = randomRangeFromSeed(buildKey(doc.id, 'rotation'), -ROTATION_RANGE, ROTATION_RANGE); + maxZ += 1; + next.set(doc.id, { centerX, centerY, rotation, z: maxZ }); + }); + + layoutRef.current = next; + zCounterRef.current = Math.max(zCounterRef.current, maxZ); + syncLayoutSnapshot(); + }, [items, canvasSize.width, canvasSize.height, ensureDocumentSize, syncLayoutSnapshot]); + + useEffect(() => { + if (draggingId && !items.some((doc) => doc.id === draggingId)) { + setDraggingId(null); + } + }, [draggingId, items]); + + const bringToFront = useCallback( + (docId) => { + zCounterRef.current += 1; + const entry = layoutRef.current.get(docId); + if (!entry) return; + const updated = { ...entry, z: zCounterRef.current }; + layoutRef.current.set(docId, updated); + syncLayoutSnapshot(); + }, + [syncLayoutSnapshot], + ); + + const toggleZoom = useCallback( + (docId) => { + setZoomedId((current) => { + if (current === docId) { + return null; + } + bringToFront(docId); + return docId; + }); + }, + [bringToFront], + ); + const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = useDocumentDrag({ + layoutRef, + itemRefs, + documentLookup, + ensureDocumentSize, + resolveBaseMetrics, + bringToFront, + setDraggingId, + syncLayoutSnapshot, + canvasSize, + toggleZoom, + }); + + const handleTagDragEnterDoc = useCallback( + (event, docId) => { + if (!isTagTransfer(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + updateRemovalCursor(false); + if (tagDropTargetId !== docId) { + setTagDropTargetId(docId); + } + }, + [isTagTransfer, tagDropTargetId, updateRemovalCursor], + ); + + const handleTagDragOverDoc = useCallback( + (event, docId) => { + if (!isTagTransfer(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + updateRemovalCursor(false); + const activeDrag = draggingTagRef.current; + event.dataTransfer.dropEffect = activeDrag?.sourceDocId ? 'move' : 'copy'; + if (tagDropTargetId !== docId) { + setTagDropTargetId(docId); + } + }, + [isTagTransfer, tagDropTargetId, updateRemovalCursor], + ); + + const handleTagDragLeaveDoc = useCallback((event, docId) => { + if (!isTagTransfer(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if ( + event.currentTarget instanceof HTMLElement && + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + setTagDropTargetId((current) => (current === docId ? null : current)); + updateRemovalCursor(false); + }, [isTagTransfer, updateRemovalCursor]); + + const markActiveTagDropHandled = useCallback((tagId, sourceDocId = null) => { + const state = draggingTagRef.current; + if (!state) { + return; + } + if (state.tagId !== tagId) { + return; + } + if (sourceDocId && state.sourceDocId !== sourceDocId) { + return; + } + state.dropHandled = true; + }, []); + + const handleTagDropOnDoc = useCallback( + async (event, doc) => { + if (!doc || !isTagTransfer(event) || typeof onAssignTagToDocument !== 'function') { + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: drop ignored', { doc, hasTransfer: isTagTransfer(event) }); + } + return; + } + event.preventDefault(); + event.stopPropagation(); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: drop accepted for doc', doc.id, 'event', event); + } + setTagDropTargetId(null); + + let payload = null; + try { + const raw = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + payload = raw ? JSON.parse(raw) : null; + } catch (error) { + if (DEBUG_DROP) { + console.warn('[skeuo] handleTagDropOnDoc: failed to parse payload', error); + } + payload = null; + } + + if (!payload?.id) { + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: missing tag id payload', payload); + } + queueFocusCanvas(); + return; + } + + const tagId = payload.id; + const sourceDocId = payload.sourceDocId || null; + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: parsed payload', { tagId, sourceDocId }); + } + + if (sourceDocId && sourceDocId === doc.id) { + markActiveTagDropHandled(tagId, sourceDocId); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: drop from same doc ignored', tagId); + } + queueFocusCanvas(); + return; + } + + const alreadyAssigned = Array.isArray(doc.tags) + ? doc.tags.some((tag) => tag.id === tagId) + : false; + if (alreadyAssigned) { + markActiveTagDropHandled(tagId, sourceDocId); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: tag already assigned', tagId); + } + queueFocusCanvas(); + return; + } + + const movingBetweenDocuments = Boolean(sourceDocId && sourceDocId !== doc.id); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: movingBetweenDocuments', movingBetweenDocuments); + } + + setPendingTagDocId(doc.id); + try { + await onAssignTagToDocument({ documentId: doc.id, tagId }); + markActiveTagDropHandled(tagId, sourceDocId); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); + } + if (movingBetweenDocuments && typeof onRemoveTagFromDocument === 'function') { + setPendingRemovalTag({ docId: sourceDocId, tagId }); + try { + await onRemoveTagFromDocument(sourceDocId, tagId); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: removed tag from source doc', sourceDocId); + } + } finally { + setPendingRemovalTag(null); + } + } + } finally { + setPendingTagDocId(null); + if (DEBUG_DROP) { + console.log('[skeuo] handleTagDropOnDoc: finalizing drop for tag', tagId); + } + queueFocusCanvas(); + } + }, + [ + isTagTransfer, + markActiveTagDropHandled, + onAssignTagToDocument, + onRemoveTagFromDocument, + queueFocusCanvas, + ], + ); + + const handleCanvasDragOver = useCallback( + (event) => { + if (!isTagTransfer(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'move'; + updateRemovalCursor(true); + setTagDropTargetId(null); + }, + [isTagTransfer, updateRemovalCursor], + ); + + const handleCanvasDragLeave = useCallback( + (event) => { + if (!isTagTransfer(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if ( + event.currentTarget instanceof HTMLElement && + event.relatedTarget instanceof Node && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + updateRemovalCursor(false); + }, + [isTagTransfer, updateRemovalCursor], + ); + + const handleCanvasDrop = useCallback( + async (event) => { + if (!isTagTransfer(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + updateRemovalCursor(false); + queueFocusCanvas(); + setTagDropTargetId(null); + + let payload = null; + try { + const raw = + event.dataTransfer.getData('application/x-papercrate-tag') || + event.dataTransfer.getData('text/papercrate-tag'); + payload = raw ? JSON.parse(raw) : null; + } catch (error) { + payload = null; + } + + if (!payload?.id) { + return; + } + + const tagId = payload.id; + const sourceDocId = payload.sourceDocId || null; + markActiveTagDropHandled(tagId, sourceDocId); + + if (!sourceDocId || typeof onRemoveTagFromDocument !== 'function') { + return; + } + + setPendingRemovalTag({ docId: sourceDocId, tagId }); + try { + await onRemoveTagFromDocument(sourceDocId, tagId); + } catch (error) { + console.error('Failed to remove tag via canvas drop', error); + } finally { + setPendingRemovalTag(null); + } + }, + [ + isTagTransfer, + markActiveTagDropHandled, + onRemoveTagFromDocument, + queueFocusCanvas, + updateRemovalCursor, + ], + ); + + const handleDocTagPointerDown = useCallback((event, doc, tag) => { + event.stopPropagation(); + if (!doc || !tag) { + pendingDocTagDragRef.current = null; + return; + } + const startX = Number.isFinite(event.clientX) + ? event.clientX + : Number.isFinite(event.pageX) + ? event.pageX + : 0; + const startY = Number.isFinite(event.clientY) + ? event.clientY + : Number.isFinite(event.pageY) + ? event.pageY + : 0; + pendingDocTagDragRef.current = { + docId: doc.id, + tagId: tag.id, + startX, + startY, + }; + updateRemovalCursor(false); + }, [updateRemovalCursor]); + + const handleDocTagDragStart = useCallback( + (event, doc, tag) => { + if (!doc || !tag) { + return; + } + event.stopPropagation(); + try { + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'copyMove'; + } + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + + const payload = JSON.stringify({ id: tag.id, label: tag.label, sourceDocId: doc.id }); + try { + event.dataTransfer?.setData('application/x-papercrate-tag', payload); + event.dataTransfer?.setData('text/papercrate-tag', payload); + event.dataTransfer?.setData('text/plain', tag.label || 'Tag'); + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + + const pending = pendingDocTagDragRef.current; + const node = event.currentTarget; + const startX = Number.isFinite(event.clientX) + ? event.clientX + : Number.isFinite(event.pageX) + ? event.pageX + : 0; + const startY = Number.isFinite(event.clientY) + ? event.clientY + : Number.isFinite(event.pageY) + ? event.pageY + : 0; + const initialX = + pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startX) + ? pending.startX + : startX; + const initialY = + pending && pending.docId === doc.id && pending.tagId === tag.id && Number.isFinite(pending.startY) + ? pending.startY + : startY; + pendingDocTagDragRef.current = null; + let preview = null; + if (node instanceof HTMLElement) { + preview = createDragPreview(node, event.clientX, event.clientY); + if (preview && event.dataTransfer) { + try { + event.dataTransfer.setDragImage(preview.clone, preview.offsetX, preview.offsetY); + } catch ( + // eslint-disable-next-line no-empty + error + ) {} + } + } + draggingTagRef.current = { + sourceDocId: doc.id, + tagId: tag.id, + tagLabel: tag.label || 'Tag', + startX: initialX, + startY: initialY, + distance: 0, + element: node instanceof HTMLElement ? node : null, + dropHandled: false, + hasPosition: Number.isFinite(initialX) && Number.isFinite(initialY), + previewClone: preview?.clone || null, + }; + + const hideNode = () => { + if (draggingTagRef.current?.element === node) { + node.classList.add('is-drag-hidden'); + } + }; + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(hideNode); + } else { + setTimeout(hideNode, 0); + } + }, + [], + ); + + const handleDocTagDrag = useCallback( + (event) => { + const state = draggingTagRef.current; + if (!state) { + updateRemovalCursor(false); + return; + } + if (!state.hasPosition && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) { + state.startX = event.clientX; + state.startY = event.clientY; + state.hasPosition = true; + } + const clientX = Number.isFinite(event.clientX) ? event.clientX : state.startX; + const clientY = Number.isFinite(event.clientY) ? event.clientY : state.startY; + const deltaX = clientX - state.startX; + const deltaY = clientY - state.startY; + const distance = Math.hypot(deltaX, deltaY); + if (Number.isFinite(distance)) { + state.distance = distance; + } + const removalActive = + Boolean(state.sourceDocId) && + !tagDropTargetId && + Number.isFinite(state.distance) && + state.distance >= TAG_REMOVE_DISTANCE; + updateRemovalCursor(removalActive); + }, + [tagDropTargetId, updateRemovalCursor], + ); + + const handleDocTagDragEnd = useCallback( + (event) => { + handleTagDragEnd(); + const state = draggingTagRef.current; + if (!state) { + return; + } + draggingTagRef.current = null; + + const node = state.element; + const showNode = () => { + if (node instanceof HTMLElement) { + node.classList.remove('is-drag-hidden'); + } + }; + cleanupPreview(state.previewClone); + + const scheduleShowNode = () => { + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(showNode); + } else { + setTimeout(showNode, 0); + } + }; + + const dropEffect = event?.dataTransfer?.dropEffect || 'none'; + console.log('[skeuo] dragEnd dropEffect', dropEffect, 'dropHandled', state.dropHandled); + const shouldRemove = + !state.dropHandled && + dropEffect === 'none' && + state.sourceDocId && + typeof onRemoveTagFromDocument === 'function' && + (state.distance || 0) >= TAG_REMOVE_DISTANCE; + + if (!shouldRemove) { + console.log('[skeuo] dragEnd -> no removal. distance:', state.distance); + scheduleShowNode(); + queueFocusCanvas(); + updateRemovalCursor(false); + return; + } + + queueFocusCanvas(); + updateRemovalCursor(false); + setPendingRemovalTag({ docId: state.sourceDocId, tagId: state.tagId }); + void (async () => { + try { + await onRemoveTagFromDocument(state.sourceDocId, state.tagId); + console.log('[skeuo] dragEnd -> removed tag due to fling'); + } catch (error) { + console.error('Failed to remove tag after drag', error); + scheduleShowNode(); + } finally { + setPendingRemovalTag(null); + } + })(); + }, + [queueFocusCanvas, handleTagDragEnd, onRemoveTagFromDocument, updateRemovalCursor], + ); + + const renderBreadcrumbs = () => { + if (!breadcrumbs.length) return null; + return ( + + ); + }; + + return ( +
+
+
+

{currentFolderName}

+ {renderBreadcrumbs()} + {showingSearchResults && Showing search results} +
+
+ + +
+
+
+ {items.length === 0 ? ( +
+

No documents to show here yet. Drop files to make this space come alive.

+
+ ) : ( + items.map((doc) => { + const { width: cardWidth, height: cardHeight } = ensureDocumentSize(doc); + const { baseWidth, baseHeight, baseScale } = resolveBaseMetrics(doc, cardWidth, cardHeight); + const fallbackLayout = { + centerX: CANVAS_PADDING + cardWidth / 2, + centerY: CANVAS_PADDING + cardHeight / 2, + rotation: 0, + z: 1, + }; + const storedLayout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id); + const layout = storedLayout || fallbackLayout; + const layoutCenterX = + typeof layout.centerX === 'number' ? layout.centerX : fallbackLayout.centerX; + const layoutCenterY = + typeof layout.centerY === 'number' ? layout.centerY : fallbackLayout.centerY; + const isZoomed = zoomedId === doc.id; + const { zoomWidth, zoomHeight, zoomCenterX, zoomCenterY } = resolveZoomMetrics( + doc, + cardWidth, + cardHeight, + ); + const targetCenterX = isZoomed ? zoomCenterX : layoutCenterX; + const targetCenterY = isZoomed ? zoomCenterY : layoutCenterY; + const rotation = isZoomed ? 0 : layout.rotation || 0; + const zoomScale = isZoomed + ? Math.min( + 1, + Number.isFinite(zoomWidth / baseWidth) ? zoomWidth / baseWidth : 1, + Number.isFinite(zoomHeight / baseHeight) ? zoomHeight / baseHeight : 1, + ) + : baseScale; + const transform = formatTransform( + Math.round(targetCenterX), + Math.round(targetCenterY), + rotation, + zoomScale, + ); + const style = { + transform, + zIndex: isZoomed ? 9999 : layout.z ?? 1, + }; + const bodyStyle = { + width: Math.round(baseWidth), + height: Math.round(baseHeight), + }; + const totalScale = zoomScale > 0 ? zoomScale : 1; + const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1; + const tagsStyle = { '--tag-scale': inverseTagScale }; + const previewUrl = resolvePreviewUrl(doc, resolveAssetPath); + const imageUrl = previewUrl; + const title = doc.title || doc.original_name || 'Document'; + const dragging = draggingId === doc.id; + const tags = Array.isArray(doc.tags) ? doc.tags : []; + const docTagKeys = tags + .map((tag) => resolveTagKey(tag)) + .filter(Boolean); + const matchesFilter = + !activeShelfTagId || docTagKeys.includes(activeShelfTagId); + const dropActive = tagDropTargetId === doc.id; + const dropPending = pendingTagDocId === doc.id; + const itemClasses = ['skeuo-item']; + if (dragging) itemClasses.push('is-dragging'); + if (isZoomed) itemClasses.push('is-zoomed'); + if (dropActive) itemClasses.push('is-tag-target'); + if (dropPending) itemClasses.push('is-tag-pending'); + if (!matchesFilter) itemClasses.push('is-filtered-out'); + const docTagTokens = docTagKeys.join(' '); + return ( +
{ + if (node) { + itemRefs.current.set(doc.id, node); + } else { + itemRefs.current.delete(doc.id); + } + }} + onPointerDown={(event) => + handlePointerDown(event, doc.id, { lockWhenZoomed: isZoomed }) + } + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerCancel} + onDragEnter={(event) => handleTagDragEnterDoc(event, doc.id)} + onDragOver={(event) => handleTagDragOverDoc(event, doc.id)} + onDragLeave={(event) => handleTagDragLeaveDoc(event, doc.id)} + onDrop={(event) => handleTagDropOnDoc(event, doc)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onDocumentOpen?.(doc.id); + } + }} + > +
+
+ {imageUrl ? ( + {title} + ) : ( +
DOC
+ )} +
+ {tags.length > 0 && ( + + )} +
+
+ ); + }) + )} + {(availableTags.length > 0 || onCreateTag) && ( +
+ {availableTags.map((tag) => { + const colorValue = normalizeColor(tag.color); + const foreground = getContrastingTextColor(colorValue || '#1b1f24'); + const tagStyle = colorValue + ? { backgroundColor: colorValue, color: foreground } + : undefined; + const tagKey = resolveTagKey(tag); + const isSelected = activeShelfTagId === tagKey; + const shelfTagClasses = ['skeuo-tag']; + if (isSelected) { + shelfTagClasses.push('is-active'); + } else if (activeShelfTagId) { + shelfTagClasses.push('is-inactive'); + } + return ( +
handleShelfTagDragStart(event, tag)} + onDragEnd={handleShelfTagDragEndWithReset} + role="button" + tabIndex={0} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + setActiveShelfTagId((current) => (current === tagKey ? null : tagKey)); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setActiveShelfTagId((current) => (current === tagKey ? null : tagKey)); + } + }} + > + {tag.label} +
+ ); + })} + {typeof onCreateTag === 'function' && ( + + )} +
+ )} +
+
+ ); +}; + +export default SkeuomorphicWorkspace; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 62a330c..caadfb1 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -691,7 +691,7 @@ button.icon-button.ghost:hover:not([disabled]) { .thumb-placeholder { width: 36px; height: 48px; - border-radius: 0; + border-radius: 1px; color: var(--muted); display: inline-flex; align-items: center;