78 Commits
Author SHA1 Message Date
nils a3c6a7c633 license 2025-10-17 18:06:08 +02:00
nils fc3d6cb826 foo 2025-10-17 18:03:00 +02:00
nils f9e798f3b3 include_documents for /contents 2025-10-17 18:01:07 +02:00
nils 831ec1d989 fix 2025-10-17 17:56:09 +02:00
nils 757536c277 remove path_cache 2025-10-17 17:42:20 +02:00
nils d003f0783d count pages per version 2025-10-17 17:34:23 +02:00
nils 5666dc4a4b style 2025-10-17 17:19:45 +02:00
nils c2b0351598 ocr view 2025-10-16 18:03:31 +02:00
nils a8a3d95c40 lowercase before index 2025-10-16 17:30:41 +02:00
nils 97e800bfd5 view ocr text 2025-10-16 17:10:23 +02:00
nils d7c65ed551 fix
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 16:22:58 +02:00
nils 9405f07f06 style
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 3m24s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 3m43s
2025-10-16 16:15:36 +02:00
nils 41f17733a5 grid view
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has started running
2025-10-16 16:03:51 +02:00
nils 3019172b88 backend 2025-10-16 15:10:21 +02:00
nils b741c181e0 tags drags
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 13:51:12 +02:00
nils e45574779b fixes 2025-10-16 12:32:03 +02:00
nils fecb7af3f6 sidebar tags style 2025-10-16 12:22:19 +02:00
nils e1e046f846 fixes
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 8m18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 8m17s
2025-10-16 12:07:42 +02:00
nils d1f0a2d48b search
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-16 11:59:36 +02:00
nils b9057f6d56 frontend: separate views
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m53s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m51s
2025-10-16 11:16:44 +02:00
nils 44b59edcb3 backend: reduce number of concurrent db connections 2025-10-16 11:10:53 +02:00
nils 05dffec42e design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 22s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-16 10:38:13 +02:00
nils aef34791f3 a lot of stuff
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m29s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m28s
2025-10-16 00:38:03 +02:00
nils 5b420a6b62 update filename on title change
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 13m52s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 13m57s
2025-10-15 22:00:14 +02:00
nils 939f248499 unique (folder_id, title) for documents 2025-10-15 21:56:20 +02:00
nils a8d362ff89 index documents (folder_id, title) 2025-10-15 21:36:19 +02:00
nils 068f96880b tags, folder rename, other stuff 2025-10-15 21:32:17 +02:00
nils b5119a8e6f frontend: desk layout
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 45s
2025-10-15 19:52:15 +02:00
nils 6b89913efd design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 43s
2025-10-15 17:46:18 +02:00
nils 01c8599853 changes
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s
2025-10-15 17:19:26 +02:00
nils 43d27cfd27 thumbnails
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 45s
2025-10-15 16:56:34 +02:00
nils 2d2e046c9d dragging 2025-10-15 16:50:45 +02:00
nils abee49a335 fixes 2025-10-15 16:16:37 +02:00
nils 5b5c1c4c0b design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 22s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 47s
2025-10-15 15:03:19 +02:00
nils 5c8cd98aac correspondents
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m8s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m7s
2025-10-15 00:20:54 +02:00
nils 3db221cce4 tag deletion 2025-10-14 23:35:29 +02:00
nils 60939306e7 frontend: size format 2025-10-14 23:23:26 +02:00
nils 80161fe86e frontend 2025-10-14 23:20:42 +02:00
nils 4c36ea2e9a backend: correspondents 2025-10-14 23:20:42 +02:00
nils 32432026db backend: health without auth 2025-10-14 22:37:20 +02:00
nils 8a9f3644a1 split
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s
2025-10-14 15:49:33 +02:00
nils d7f221850f design
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 1m1s
2025-10-14 12:21:39 +02:00
nils 4d5f7b4ccd desk view 2025-10-14 10:27:13 +02:00
nils 9aa43d2753 7
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 12m50s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 12m49s
2025-10-14 02:43:38 +02:00
nils c17a9484ca a lot of things
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 9m32s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 9m48s
2025-10-14 00:56:25 +02:00
nils 9060905e7a assets 2025-10-13 14:49:37 +02:00
nils 159577dff9 backend: generate high resolution preview 2025-10-13 02:39:02 +02:00
nils 86c75cea8e frontend: keyboard nav 2025-10-12 18:56:10 +02:00
nils 3f0c607383 frontend api fix 2025-10-12 18:55:58 +02:00
nils 1cc230985f frontend: fix document deletion
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 16s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 33s
2025-10-12 16:54:42 +02:00
nils 9cd69dc00c frontend: fix uploads
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 33s
2025-10-12 16:01:28 +02:00
nils 111d2cada3 helm chart 2025-10-12 15:14:01 +02:00
nils a55a476d97 backend
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m2s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m20s
2025-10-12 14:08:44 +02:00
nils 6724634870 backend 2025-10-12 14:07:10 +02:00
nils 797a641263 frontend 2025-10-12 14:07:02 +02:00
nils a2d7caa6e4 rm libpdfium from git 2025-10-12 13:53:16 +02:00
nils 351d635f39 frontend api url
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 32s
2025-10-12 13:40:27 +02:00
nils 9147442822 fix dockerfile
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 6s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 28s
2025-10-12 13:10:04 +02:00
nils 6987fe1bfe frontend: login route
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 4m50s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 4m52s
2025-10-12 12:23:45 +02:00
nils 4b74ee54b0 rename backend binary
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-12 12:13:46 +02:00
nils dc22b12df3 rename to papercrate 2025-10-12 02:35:56 +02:00
nils d9a36a99a7 dockerfiles, TagsWorkspace 2025-10-12 01:59:26 +02:00
nils f30e455c2d patch tags 2025-10-12 01:52:30 +02:00
nils 1b950a8f9a fix breadcrumbs 2025-10-11 22:47:31 +02:00
nils 3a5d7607e3 debouncing delay 2025-10-11 22:17:26 +02:00
nils 7233e3a534 basic search 2025-10-11 17:05:12 +02:00
nils 0a669064dd quickwit 2025-10-11 16:42:25 +02:00
nils cd27f36e3d ocr 2025-10-11 14:39:58 +02:00
nils 6f6abef5d2 logout 2025-10-11 13:36:27 +02:00
nils a21853c874 preview stack, refresh token, design 2025-10-11 13:12:05 +02:00
nils 6850918e3d 6 2025-10-11 11:54:57 +02:00
nils 41a9e8f76c preview-stack 2025-10-11 11:09:38 +02:00
nils 30f3fe015b 4 2025-10-10 22:26:03 +02:00
nils 5fd4a41e64 frontend: preview 2025-10-10 18:59:19 +02:00
nils dfadc8ba23 3 2025-10-10 15:53:38 +02:00
nils ddce0e39b3 2 2025-10-10 09:39:58 +02:00
nils 4a428b9af6 tests 2025-10-09 22:41:04 +02:00
nils 768f8cb21c 1 2025-10-09 22:16:29 +02:00
32 changed files with 891 additions and 2825 deletions
-10
View File
@@ -1,15 +1,5 @@
# Papercrate # Papercrate
## Local Development
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
```bash
tmux -f papercrate.tmux attach
```
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
## Backend Integration Tests ## Backend Integration Tests
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs: Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
@@ -1,34 +0,0 @@
ALTER TABLE document_assets
ADD COLUMN s3_key TEXT;
UPDATE document_assets AS da
SET s3_key = dao.s3_key
FROM document_asset_objects AS dao
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND da.s3_key IS NULL;
ALTER TABLE document_assets
ALTER COLUMN s3_key SET NOT NULL;
UPDATE document_assets AS da
SET metadata = jsonb_set(da.metadata, '{width}', dao.metadata->'width', true)
FROM document_asset_objects AS dao
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND dao.metadata ? 'width';
UPDATE document_assets AS da
SET metadata = jsonb_set(da.metadata, '{height}', dao.metadata->'height', true)
FROM document_asset_objects AS dao
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND dao.metadata ? 'height';
DROP INDEX IF EXISTS idx_document_asset_objects_asset_ordinal;
DROP TABLE IF EXISTS document_asset_objects;
ALTER TABLE document_assets
DROP CONSTRAINT IF EXISTS document_assets_cardinality_positive;
ALTER TABLE document_assets
DROP COLUMN IF EXISTS cardinality;
@@ -1,54 +0,0 @@
ALTER TABLE document_assets
ADD COLUMN cardinality INTEGER,
ADD CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1);
CREATE TABLE document_asset_objects (
id UUID PRIMARY KEY,
asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL,
s3_key TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1),
CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal)
);
CREATE INDEX idx_document_asset_objects_asset_ordinal
ON document_asset_objects (asset_id, ordinal);
INSERT INTO document_asset_objects (id, asset_id, ordinal, s3_key, metadata)
SELECT
gen_random_uuid(),
id,
1,
s3_key,
'{}'::jsonb
FROM document_assets;
UPDATE document_asset_objects AS dao
SET metadata = jsonb_set(dao.metadata, '{width}', da.metadata->'width', true)
FROM document_assets AS da
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND da.metadata ? 'width';
UPDATE document_asset_objects AS dao
SET metadata = jsonb_set(dao.metadata, '{height}', da.metadata->'height', true)
FROM document_assets AS da
WHERE dao.asset_id = da.id
AND dao.ordinal = 1
AND da.metadata ? 'height';
UPDATE document_assets
SET metadata = metadata - 'width'
WHERE metadata ? 'width';
UPDATE document_assets
SET metadata = metadata - 'height'
WHERE metadata ? 'height';
UPDATE document_assets
SET cardinality = 1
WHERE cardinality IS NULL;
ALTER TABLE document_assets
DROP COLUMN s3_key;
+5 -13
View File
@@ -2,14 +2,13 @@ use std::env;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use diesel::prelude::*; use diesel::prelude::*;
use uuid::Uuid;
use backend::{ use backend::{
config::AppConfig, config::AppConfig,
db, db,
models::{DocumentAsset, DocumentAssetObject}, models::DocumentAsset,
s3, s3,
schema::{document_asset_objects, document_assets}, schema::document_assets,
storage::{ObjectStorage, S3Storage}, storage::{ObjectStorage, S3Storage},
}; };
@@ -58,18 +57,11 @@ async fn delete_all_assets() -> Result<()> {
println!("Deleting {} assets…", assets.len()); println!("Deleting {} assets…", assets.len());
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect(); for asset in &assets {
if let Err(err) = storage.delete_object(&asset.s3_key).await {
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
.load(&mut conn)
.context("failed to load document asset objects")?;
for object in &objects {
if let Err(err) = storage.delete_object(&object.s3_key).await {
eprintln!( eprintln!(
"Failed to delete object {} from storage: {err}", "Failed to delete object {} from storage: {err}",
object.s3_key asset.s3_key
); );
} }
} }
+2 -23
View File
@@ -109,10 +109,10 @@ pub struct DocumentAsset {
pub id: Uuid, pub id: Uuid,
pub document_version_id: Uuid, pub document_version_id: Uuid,
pub asset_type: String, pub asset_type: String,
pub s3_key: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub cardinality: Option<i32>,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -121,30 +121,9 @@ pub struct NewDocumentAsset {
pub id: Uuid, pub id: Uuid,
pub document_version_id: Uuid, pub document_version_id: Uuid,
pub asset_type: String, pub asset_type: String,
pub s3_key: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub cardinality: Option<i32>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_asset_objects)]
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
pub struct DocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_asset_objects)]
pub struct NewDocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
+44 -166
View File
@@ -22,13 +22,12 @@ use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT}; use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{ use crate::models::{
Correspondent, Document, DocumentAsset, DocumentAssetObject, DocumentCorrespondent, Correspondent, Document, DocumentAsset, DocumentCorrespondent, DocumentVersion, NewDocument,
DocumentVersion, NewDocument, NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, Tag,
Tag,
}; };
use crate::schema::{ use crate::schema::{
correspondents, document_asset_objects, document_assets, document_correspondents, correspondents, document_assets, document_correspondents, document_tags, document_versions,
document_tags, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
}; };
use crate::state::AppState; use crate::state::AppState;
@@ -109,8 +108,7 @@ pub struct DocumentVersionResponse {
pub checksum: String, pub checksum: String,
pub created_at: String, pub created_at: String,
pub metadata: Value, pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")] pub operations_summary: Value,
pub operations_summary: Option<Value>,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@@ -120,31 +118,8 @@ pub struct DocumentAssetResponse {
pub mime_type: String, pub mime_type: String,
pub metadata: Value, pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
}
#[derive(Serialize, Clone)]
pub struct DocumentAssetObjectResponse {
pub id: Uuid,
pub ordinal: i32,
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>, pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
}
#[derive(Serialize)]
pub struct DocumentAssetDetailResponse {
pub id: Uuid,
pub asset_type: String,
pub mime_type: String,
pub metadata: Value,
pub created_at: String, pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub objects: Vec<DocumentAssetObjectResponse>,
} }
#[derive(Serialize, Clone)] #[derive(Serialize, Clone)]
@@ -360,14 +335,6 @@ pub struct AssignTagsRequest {
pub tag_ids: Vec<Uuid>, pub tag_ids: Vec<Uuid>,
} }
#[derive(Deserialize, Default)]
pub struct AssetObjectsQuery {
#[serde(default)]
pub start: Option<i32>,
#[serde(default)]
pub limit: Option<i32>,
}
pub async fn list_documents( pub async fn list_documents(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<DocumentListQuery>, Query(params): Query<DocumentListQuery>,
@@ -638,7 +605,7 @@ pub async fn get_document(
drop(conn); drop(conn);
let assets = load_asset_responses(&state, version_id).await?; let assets = load_asset_responses(&state, version_id).await?;
let version_response = to_version_response(current_version, true); let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse { Ok(Json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
@@ -881,70 +848,33 @@ pub async fn list_document_assets(
pub async fn get_document_asset( pub async fn get_document_asset(
State(state): State<AppState>, State(state): State<AppState>,
Path(asset_id): Path<Uuid>, Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
Query(query): Query<AssetObjectsQuery>, ) -> AppResult<Json<DocumentAssetResponse>> {
) -> AppResult<Json<DocumentAssetDetailResponse>> {
let mut conn = state.db()?; let mut conn = state.db()?;
let document: Document = documents::table.find(document_id).first(&mut conn)?;
let asset: DocumentAsset = match document_assets::table if document.deleted_at.is_some() {
.find(asset_id)
.first(&mut conn)
.optional()?
{
Some(asset) => asset,
None => return Err(AppError::not_found()),
};
let start = query.start.unwrap_or(1);
let limit = query.limit.unwrap_or(1);
if start < 1 {
return Err(AppError::bad_request("start must be at least 1"));
}
if limit < 1 {
return Err(AppError::bad_request("limit must be at least 1"));
}
let end = start
.checked_add(limit - 1)
.ok_or_else(|| AppError::bad_request("requested range is too large"))?;
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.ge(start))
.filter(document_asset_objects::ordinal.le(end))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)?;
drop(conn);
let expires_at = Utc::now()
.timestamp_millis()
.checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
.ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
let mut object_responses = Vec::with_capacity(objects.len());
for object in objects {
let url = state
.storage
.presign_get_object(
&object.s3_key,
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
object_responses.push(to_asset_object_response(
object,
Some(url),
Some(expires_at),
));
}
if object_responses.is_empty() {
return Err(AppError::not_found()); return Err(AppError::not_found());
} }
Ok(Json(to_asset_detail_response(asset, object_responses))) 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( pub async fn download_document(
@@ -1102,7 +1032,7 @@ pub async fn update_document(
drop(conn); drop(conn);
let assets = load_asset_responses(&state, version_id).await?; let assets = load_asset_responses(&state, version_id).await?;
let version_response = to_version_response(current_version, true); let version_response = to_version_response(current_version);
Ok(Json(DocumentDetailResponse { Ok(Json(DocumentDetailResponse {
document: to_document_response( document: to_document_response(
@@ -1641,7 +1571,7 @@ async fn process_upload(
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default(); let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
drop(conn); drop(conn);
let assets = load_asset_responses(state, version.id).await?; let assets = load_asset_responses(state, version.id).await?;
let version_response = to_version_response(version.clone(), true); let version_response = to_version_response(version.clone());
info!( info!(
document_id = %document.id, document_id = %document.id,
@@ -1734,7 +1664,7 @@ async fn process_upload(
document, document,
None, None,
Vec::new(), Vec::new(),
Some((to_version_response(version.clone(), true), Vec::new())), Some((to_version_response(version.clone()), Vec::new())),
)?, )?,
}; };
@@ -1854,27 +1784,18 @@ pub(crate) async fn load_primary_assets(
version_map.insert(version.id, version); version_map.insert(version.id, version);
} }
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table let assets: Vec<DocumentAsset> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq_any(&version_ids)) .filter(document_assets::document_version_id.eq_any(&version_ids))
.order(( .order((
document_assets::document_version_id.asc(), document_assets::document_version_id.asc(),
document_assets::created_at.asc(), document_assets::created_at.asc(),
)) ))
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?; .load(&mut conn)?;
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new(); let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for (asset, _object) in assets { for asset in assets {
let version_id = asset.document_version_id; let version_id = asset.document_version_id;
let response = to_asset_summary(asset); let response = to_asset_response(asset, None);
assets_by_version assets_by_version
.entry(version_id) .entry(version_id)
.or_default() .or_default()
@@ -1888,7 +1809,7 @@ pub(crate) async fn load_primary_assets(
for (doc_id, version_id) in doc_to_version { for (doc_id, version_id) in doc_to_version {
if let Some(version) = version_map.remove(&version_id) { if let Some(version) = version_map.remove(&version_id) {
let assets = assets_by_version.remove(&version_id).unwrap_or_default(); let assets = assets_by_version.remove(&version_id).unwrap_or_default();
result.insert(doc_id, (to_version_response(version, false), assets)); result.insert(doc_id, (to_version_response(version), assets));
} }
} }
@@ -1944,10 +1865,7 @@ fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> Ap
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}"))) .map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
} }
fn to_version_response( fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
version: DocumentVersion,
include_operations_summary: bool,
) -> DocumentVersionResponse {
DocumentVersionResponse { DocumentVersionResponse {
id: version.id, id: version.id,
version_number: version.version_number, version_number: version.version_number,
@@ -1956,50 +1874,19 @@ fn to_version_response(
checksum: version.checksum, checksum: version.checksum,
created_at: to_iso(version.created_at), created_at: to_iso(version.created_at),
metadata: version.metadata, metadata: version.metadata,
operations_summary: if include_operations_summary { operations_summary: version.operations_summary,
Some(version.operations_summary)
} else {
None
},
} }
} }
fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse { fn to_asset_response(asset: DocumentAsset, url: Option<String>) -> DocumentAssetResponse {
let metadata = asset.metadata.clone();
DocumentAssetResponse { DocumentAssetResponse {
id: asset.id, id: asset.id,
asset_type: asset.asset_type, asset_type: asset.asset_type,
mime_type: asset.mime_type, mime_type: asset.mime_type,
metadata: asset.metadata, metadata,
cardinality: asset.cardinality,
}
}
fn to_asset_detail_response(
asset: DocumentAsset,
objects: Vec<DocumentAssetObjectResponse>,
) -> DocumentAssetDetailResponse {
DocumentAssetDetailResponse {
id: asset.id,
asset_type: asset.asset_type,
mime_type: asset.mime_type,
metadata: asset.metadata,
created_at: to_iso(asset.created_at),
cardinality: asset.cardinality,
objects,
}
}
fn to_asset_object_response(
object: DocumentAssetObject,
url: Option<String>,
expires_at: Option<i64>,
) -> DocumentAssetObjectResponse {
DocumentAssetObjectResponse {
id: object.id,
ordinal: object.ordinal,
metadata: object.metadata,
url, url,
expires_at, created_at: to_iso(asset.created_at),
} }
} }
@@ -2044,24 +1931,15 @@ async fn load_asset_responses(
version_id: Uuid, version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> { ) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db()?; let mut conn = state.db()?;
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = document_assets::table let assets: Vec<DocumentAsset> = document_assets::table
.left_outer_join(
document_asset_objects::table.on(document_asset_objects::asset_id
.eq(document_assets::id)
.and(document_asset_objects::ordinal.eq(1))),
)
.filter(document_assets::document_version_id.eq(version_id)) .filter(document_assets::document_version_id.eq(version_id))
.order(document_assets::created_at.asc()) .order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?; .load(&mut conn)?;
drop(conn); drop(conn);
Ok(assets Ok(assets
.into_iter() .into_iter()
.map(|(asset, _object)| to_asset_summary(asset)) .map(|asset| to_asset_response(asset, None))
.collect()) .collect())
} }
+1 -3
View File
@@ -75,6 +75,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.patch(documents::update_document), .patch(documents::update_document),
) )
.route("/:id/download", get(documents::download_document)) .route("/:id/download", get(documents::download_document))
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
.route( .route(
"/:id/assets", "/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets), get(documents::list_document_assets).post(documents::request_document_assets),
@@ -119,14 +120,11 @@ pub fn create_router(state: AppState) -> Router<()> {
); );
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
let protected_routes = Router::new() let protected_routes = Router::new()
.nest("/api/documents", documents_routes) .nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes) .nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes) .nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_routes) .nest("/api/correspondents", correspondents_routes)
.nest("/api/assets", assets_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new() Router::new()
+1 -13
View File
@@ -11,25 +11,15 @@ diesel::table! {
} }
} }
diesel::table! {
document_asset_objects (id) {
id -> Uuid,
asset_id -> Uuid,
ordinal -> Int4,
s3_key -> Text,
metadata -> Jsonb,
}
}
diesel::table! { diesel::table! {
document_assets (id) { document_assets (id) {
id -> Uuid, id -> Uuid,
document_version_id -> Uuid, document_version_id -> Uuid,
asset_type -> Text, asset_type -> Text,
s3_key -> Text,
mime_type -> Text, mime_type -> Text,
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>,
} }
} }
@@ -153,7 +143,6 @@ diesel::table! {
} }
} }
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_id)); diesel::joinable!(document_correspondents -> documents (document_id));
@@ -166,7 +155,6 @@ diesel::joinable!(refresh_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
correspondents, correspondents,
document_asset_objects,
document_assets, document_assets,
document_correspondents, document_correspondents,
document_tags, document_tags,
+8 -13
View File
@@ -12,8 +12,8 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::JOB_INDEX_DOCUMENT_TEXT, jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentVersion}, models::{Document, DocumentAsset, DocumentVersion},
schema::{document_asset_objects, document_assets, document_versions, documents}, schema::{document_assets, document_versions, documents},
state::AppState, state::AppState,
}; };
@@ -87,15 +87,15 @@ impl JobHandler for IndexDocumentTextJob {
} }
}; };
if context.text_s3_key.is_none() { if context.text_asset.is_none() {
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job"); warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
return JobExecution::Failed { return JobExecution::Failed {
error: "missing OCR text asset".into(), error: "missing OCR text asset".into(),
}; };
} }
let s3_key = context.text_s3_key.unwrap(); let asset = context.text_asset.unwrap();
let text = match state.storage.get_object(&s3_key).await { let text = match state.storage.get_object(&asset.s3_key).await {
Ok(bytes) => match String::from_utf8(bytes) { Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text, Ok(text) => text,
Err(err) => { Err(err) => {
@@ -169,7 +169,7 @@ impl JobHandler for IndexDocumentTextJob {
struct IndexContext { struct IndexContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
text_s3_key: Option<String>, text_asset: Option<DocumentAsset>,
} }
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> { fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
@@ -189,14 +189,9 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let text_s3_key: Option<String> = document_asset_objects::table let text_asset: Option<DocumentAsset> = document_assets::table
.inner_join(
document_assets::table.on(document_asset_objects::asset_id.eq(document_assets::id)),
)
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_asset_objects::ordinal.eq(1))
.select(document_asset_objects::s3_key)
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
@@ -204,6 +199,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
Ok(IndexContext { Ok(IndexContext {
document, document,
version, version,
text_s3_key, text_asset,
}) })
} }
+13 -71
View File
@@ -19,11 +19,8 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT}, jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{ models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, schema::{document_assets, document_versions, documents},
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
}; };
@@ -126,15 +123,11 @@ impl JobHandler for GenerateOcrTextJob {
}; };
}; };
if context.existing_asset.is_some() { let asset_id = context
for object in &context.existing_objects { .existing_asset
if let Err(err) = state.storage.delete_object(&object.s3_key).await { .as_ref()
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object"); .map(|asset| asset.id)
} .unwrap_or_else(Uuid::new_v4);
}
}
let asset_id = Uuid::new_v4();
let s3_key = format!( let s3_key = format!(
"documents/{}/v{}/assets/{}/{}", "documents/{}/v{}/assets/{}/{}",
@@ -200,7 +193,6 @@ struct OcrContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_asset: Option<DocumentAsset>, existing_asset: Option<DocumentAsset>,
existing_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
@@ -226,41 +218,29 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
.first(&mut conn) .first(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_asset: Option<DocumentAsset> = document_assets::table let existing: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id)) .filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE)) .filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.first(&mut conn) .first(&mut conn)
.optional() .optional()
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?
} else {
Vec::new()
};
let is_pdf = document_is_pdf(&document); let is_pdf = document_is_pdf(&document);
if !is_pdf { if !is_pdf {
return Ok(OcrContext { return Ok(OcrContext {
document, document,
version, version,
existing_asset: existing_asset, existing_asset: existing,
existing_objects,
skip: true, skip: true,
}); });
} }
let skip = existing_asset.is_some() && !payload.force; let skip = existing.is_some() && !payload.force;
Ok(OcrContext { Ok(OcrContext {
document, document,
version, version,
existing_asset, existing_asset: existing,
existing_objects,
skip, skip,
}) })
} }
@@ -391,22 +371,16 @@ fn persist_ocr_metadata(
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
if let Some(existing_asset) = &context.existing_asset {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
let new_asset = NewDocumentAsset { let new_asset = NewDocumentAsset {
id: asset_id, id: asset_id,
document_version_id: context.version.id, document_version_id: context.version.id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(), asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
s3_key: s3_key.to_string(),
mime_type: "text/plain".to_string(), mime_type: "text/plain".to_string(),
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
"source": source, "source": source,
}), }),
cardinality: Some(1),
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -417,41 +391,9 @@ fn persist_ocr_metadata(
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_object_id: Option<Uuid> = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset_id))
.filter(document_asset_objects::ordinal.eq(1))
.select(document_asset_objects::id)
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id,
ordinal: 1,
s3_key: s3_key.to_string(),
metadata: json!({}),
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.on_conflict((
document_asset_objects::asset_id,
document_asset_objects::ordinal,
))
.do_update()
.set((
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
+113 -313
View File
@@ -13,11 +13,8 @@ use uuid::Uuid;
use crate::{ use crate::{
jobs::JOB_GENERATE_THUMBNAILS, jobs::JOB_GENERATE_THUMBNAILS,
models::{ models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset, schema::{document_assets, document_versions, documents},
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState, state::AppState,
}; };
@@ -145,43 +142,12 @@ impl JobHandler for GenerateThumbnailsJob {
} }
} }
if initial.existing_preview.is_some() { let thumbnail_asset_id = initial
for object in &initial.existing_preview_objects { .existing_thumbnail
if let Err(err) = state.storage.delete_object(&object.s3_key).await { .as_ref()
warn!( .map(|asset| asset.id)
job_id = %job.id, .unwrap_or_else(Uuid::new_v4);
error = %err, let thumbnail_s3_key = format!(
s3_key = %object.s3_key,
"failed to delete existing preview object"
);
}
}
}
if initial.existing_thumbnail.is_some() {
for object in &initial.existing_thumbnail_objects {
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
warn!(
job_id = %job.id,
error = %err,
s3_key = %object.s3_key,
"failed to delete existing thumbnail object"
);
}
}
}
let preview_asset_id = Uuid::new_v4();
let preview_base = format!(
"documents/{}/v{}/assets/{}/{}",
initial.document.id,
initial.version.version_number,
PREVIEW_ASSET_TYPE,
preview_asset_id
);
let thumbnail_asset_id = Uuid::new_v4();
let thumbnail_base = format!(
"documents/{}/v{}/assets/{}/{}", "documents/{}/v{}/assets/{}/{}",
initial.document.id, initial.document.id,
initial.version.version_number, initial.version.version_number,
@@ -189,94 +155,73 @@ impl JobHandler for GenerateThumbnailsJob {
thumbnail_asset_id thumbnail_asset_id
); );
let mut preview_objects: Vec<AssetObjectPersistence> = let preview_asset_id = initial
Vec::with_capacity(generation.preview.objects.len()); .existing_preview
for (index, image) in generation.preview.objects.iter().enumerate() { .as_ref()
if index + 1 > i32::MAX as usize { .map(|asset| asset.id)
return JobExecution::Failed { .unwrap_or_else(Uuid::new_v4);
error: "too many preview objects".to_string(), let preview_s3_key = format!(
}; "documents/{}/v{}/assets/{}/{}",
} initial.document.id,
let ordinal = (index + 1) as i32; initial.version.version_number,
let s3_key = format!("{preview_base}/{ordinal}"); PREVIEW_ASSET_TYPE,
preview_asset_id
);
if let Err(err) = state if let Err(err) = state
.storage .storage
.put_object( .put_object(
&s3_key, &preview_s3_key,
image.image_bytes.clone(), generation.preview.image_bytes.clone(),
Some("image/png".into()), Some("image/png".into()),
None, None,
) )
.await .await
{ {
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying"); warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
return JobExecution::Retry { return JobExecution::Retry {
delay: Duration::from_secs(30), delay: Duration::from_secs(30),
error: err.to_string(), error: err.to_string(),
}; };
}
preview_objects.push(AssetObjectPersistence {
ordinal,
s3_key,
width: image.width,
height: image.height,
});
} }
let mut thumbnail_objects: Vec<AssetObjectPersistence> = if let Err(err) = state
Vec::with_capacity(generation.thumbnail.objects.len()); .storage
for (index, image) in generation.thumbnail.objects.iter().enumerate() { .put_object(
if index + 1 > i32::MAX as usize { &thumbnail_s3_key,
return JobExecution::Failed { generation.thumbnail.image_bytes.clone(),
error: "too many thumbnail objects".to_string(), Some("image/png".into()),
}; None,
} )
let ordinal = (index + 1) as i32; .await
let s3_key = format!("{thumbnail_base}/{ordinal}"); {
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
if let Err(err) = state return JobExecution::Retry {
.storage delay: Duration::from_secs(30),
.put_object( error: err.to_string(),
&s3_key, };
image.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
thumbnail_objects.push(AssetObjectPersistence {
ordinal,
s3_key,
width: image.width,
height: image.height,
});
} }
let asset_persistences = vec![
AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id,
objects: preview_objects,
},
AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id,
objects: thumbnail_objects,
},
];
let state_clone = state.clone(); let state_clone = state.clone();
match task::spawn_blocking(move || { match task::spawn_blocking(move || {
persist_assets_metadata(state_clone, &initial, &asset_persistences) persist_assets_metadata(
state_clone,
&initial,
&[
AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id,
s3_key: &preview_s3_key,
generated: &generation.preview,
},
AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id,
s3_key: &thumbnail_s3_key,
generated: &generation.thumbnail,
},
],
)
}) })
.await .await
{ {
@@ -305,9 +250,7 @@ struct ThumbnailContext {
document: Document, document: Document,
version: DocumentVersion, version: DocumentVersion,
existing_thumbnail: Option<DocumentAsset>, existing_thumbnail: Option<DocumentAsset>,
existing_thumbnail_objects: Vec<DocumentAssetObject>,
existing_preview: Option<DocumentAsset>, existing_preview: Option<DocumentAsset>,
existing_preview_objects: Vec<DocumentAssetObject>,
skip: bool, skip: bool,
} }
@@ -317,27 +260,17 @@ struct GeneratedImage {
height: Option<i32>, height: Option<i32>,
} }
struct GeneratedAsset {
objects: Vec<GeneratedImage>,
}
struct GeneratedAssets { struct GeneratedAssets {
thumbnail: GeneratedAsset, thumbnail: GeneratedImage,
preview: GeneratedAsset, preview: GeneratedImage,
page_count: Option<u32>, page_count: Option<u32>,
} }
struct AssetObjectPersistence { struct AssetPersistence<'a> {
ordinal: i32,
s3_key: String,
width: Option<i32>,
height: Option<i32>,
}
struct AssetPersistence {
asset_type: &'static str, asset_type: &'static str,
asset_id: Uuid, asset_id: Uuid,
objects: Vec<AssetObjectPersistence>, s3_key: &'a str,
generated: &'a GeneratedImage,
} }
fn load_thumbnail_context( fn load_thumbnail_context(
@@ -370,27 +303,11 @@ fn load_thumbnail_context(
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
let mut existing_thumbnail = None; let mut existing_thumbnail = None;
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
let mut existing_preview = None; let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
for asset in existing_assets { for asset in existing_assets {
match asset.asset_type.as_str() { match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => { THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
existing_thumbnail_objects = document_asset_objects::table PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
.filter(document_asset_objects::asset_id.eq(asset.id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_thumbnail = Some(asset);
}
PREVIEW_ASSET_TYPE => {
existing_preview_objects = document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.id))
.order(document_asset_objects::ordinal.asc())
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
existing_preview = Some(asset);
}
_ => {} _ => {}
} }
} }
@@ -400,33 +317,13 @@ fn load_thumbnail_context(
return Err("thumbnail generation not supported for this document".into()); return Err("thumbnail generation not supported for this document".into());
} }
let expected_cardinality = expected_asset_cardinality(&document, &version); let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
let preview_cardinality = existing_preview
.as_ref()
.and_then(|asset| asset.cardinality)
.unwrap_or_else(|| existing_preview_objects.len() as i32);
let thumbnail_cardinality = existing_thumbnail
.as_ref()
.and_then(|asset| asset.cardinality)
.unwrap_or_else(|| existing_thumbnail_objects.len() as i32);
let needs_regeneration = preview_cardinality < expected_cardinality
|| thumbnail_cardinality < expected_cardinality
|| (existing_preview_objects.len() as i32) < expected_cardinality
|| (existing_thumbnail_objects.len() as i32) < expected_cardinality;
let skip = existing_thumbnail.is_some()
&& existing_preview.is_some()
&& !payload.force
&& !needs_regeneration;
Ok(ThumbnailContext { Ok(ThumbnailContext {
document, document,
version, version,
existing_thumbnail, existing_thumbnail,
existing_thumbnail_objects,
existing_preview, existing_preview,
existing_preview_objects,
skip, skip,
}) })
} }
@@ -435,7 +332,18 @@ fn generate_preview_and_thumbnail(
document: &Document, document: &Document,
bytes: &[u8], bytes: &[u8],
) -> Result<GeneratedAssets, String> { ) -> Result<GeneratedAssets, String> {
let is_pdf = document_is_pdf(document); let is_pdf = document
.content_type
.as_deref()
.map(|mime| mime == "application/pdf")
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
});
if is_pdf { if is_pdf {
let pdf_assets = generate_pdf_assets(bytes)?; let pdf_assets = generate_pdf_assets(bytes)?;
@@ -454,7 +362,7 @@ fn generate_preview_and_thumbnail(
} }
} }
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> { fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
let reader = ImageReader::new(Cursor::new(bytes)) let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format() .with_guessed_format()
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
@@ -476,19 +384,12 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset
let preview = encode_dynamic_image(preview_image)?; let preview = encode_dynamic_image(preview_image)?;
let thumbnail = encode_dynamic_image(thumbnail_image)?; let thumbnail = encode_dynamic_image(thumbnail_image)?;
Ok(( Ok((preview, thumbnail))
GeneratedAsset {
objects: vec![preview],
},
GeneratedAsset {
objects: vec![thumbnail],
},
))
} }
struct PdfGeneratedAssets { struct PdfGeneratedAssets {
preview: GeneratedAsset, preview: GeneratedImage,
thumbnail: GeneratedAsset, thumbnail: GeneratedImage,
page_count: u32, page_count: u32,
} }
@@ -501,7 +402,11 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
.map_err(|err| format!("load pdf: {err}"))?; .map_err(|err| format!("load pdf: {err}"))?;
let pages = document.pages(); let pages = document.pages();
let total_pages = pages.len() as usize; let total_pages = pages.len();
let page = pages
.get(0)
.map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new() let render_config = PdfRenderConfig::new()
.set_target_width(PREVIEW_WIDTH as i32) .set_target_width(PREVIEW_WIDTH as i32)
@@ -509,44 +414,30 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
.render_form_data(true) .render_form_data(true)
.rotate_if_landscape(PdfPageRenderRotation::None, true); .rotate_if_landscape(PdfPageRenderRotation::None, true);
let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages); let bitmap = page
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages); .render_with_config(&render_config)
.map_err(|err| format!("render pdf page: {err}"))?;
for page_index in 0..total_pages { let preview_buffer = bitmap.as_image().to_rgb8();
let page = pages let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
.map_err(|err| format!("load page {page_index}: {err}"))?;
let bitmap = page let thumbnail_image =
.render_with_config(&render_config) if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
.map_err(|err| format!("render pdf page {page_index}: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8();
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
|| preview_image.height() > THUMBNAIL_HEIGHT
{
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
} else { } else {
preview_image.clone() preview_image.clone()
}; };
preview_objects.push(encode_dynamic_image(preview_image)?); let preview = encode_dynamic_image(preview_image)?;
thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?); let thumbnail = encode_dynamic_image(thumbnail_image)?;
}
let page_count: u32 = total_pages let page_count: u32 = total_pages
.try_into() .try_into()
.map_err(|_| "page count exceeds supported range".to_string())?; .map_err(|_| "page count exceeds supported range".to_string())?;
Ok(PdfGeneratedAssets { Ok(PdfGeneratedAssets {
preview: GeneratedAsset { preview,
objects: preview_objects, thumbnail,
},
thumbnail: GeneratedAsset {
objects: thumbnail_objects,
},
page_count, page_count,
}) })
} }
@@ -567,47 +458,22 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
fn persist_assets_metadata( fn persist_assets_metadata(
state: Arc<AppState>, state: Arc<AppState>,
context: &ThumbnailContext, context: &ThumbnailContext,
assets: &[AssetPersistence], assets: &[AssetPersistence<'_>],
) -> Result<(), String> { ) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?; let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
if let Some(existing_preview) = &context.existing_preview {
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
if let Some(existing_thumbnail) = &context.existing_thumbnail {
diesel::delete(
document_assets::table.filter(document_assets::id.eq(existing_thumbnail.id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
for asset in assets { for asset in assets {
if asset.objects.is_empty() {
return Err(format!(
"asset {} has no generated objects",
asset.asset_type
));
}
let object_count: i32 = asset
.objects
.len()
.try_into()
.map_err(|_| "asset contains too many objects".to_string())?;
let new_asset = NewDocumentAsset { let new_asset = NewDocumentAsset {
id: asset.asset_id, id: asset.asset_id,
document_version_id: context.version.id, document_version_id: context.version.id,
asset_type: asset.asset_type.to_string(), asset_type: asset.asset_type.to_string(),
s3_key: asset.s3_key.to_string(),
mime_type: "image/png".to_string(), mime_type: "image/png".to_string(),
metadata: json!({ metadata: json!({
"generated_at": Utc::now().to_rfc3339(), "generated_at": Utc::now().to_rfc3339(),
"width": asset.generated.width,
"height": asset.generated.height,
}), }),
cardinality: Some(object_count),
}; };
diesel::insert_into(document_assets::table) diesel::insert_into(document_assets::table)
@@ -618,44 +484,12 @@ fn persist_assets_metadata(
)) ))
.do_update() .do_update()
.set(( .set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)), document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)), document_assets::metadata.eq(excluded(document_assets::metadata)),
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
)) ))
.execute(&mut conn) .execute(&mut conn)
.map_err(|err| format!("{err:?}"))?; .map_err(|err| format!("{err:?}"))?;
diesel::delete(
document_asset_objects::table
.filter(document_asset_objects::asset_id.eq(asset.asset_id)),
)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
for object in &asset.objects {
let mut metadata_map = Map::new();
if let Some(width) = object.width {
metadata_map.insert("width".to_string(), Value::from(width));
}
if let Some(height) = object.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let object_metadata = Value::Object(metadata_map);
let new_object = NewDocumentAssetObject {
id: Uuid::new_v4(),
asset_id: asset.asset_id,
ordinal: object.ordinal,
s3_key: object.s3_key.clone(),
metadata: object_metadata,
};
diesel::insert_into(document_asset_objects::table)
.values(&new_object)
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
} }
Ok(()) Ok(())
@@ -699,37 +533,3 @@ fn persist_document_page_count(
Ok(()) Ok(())
} }
fn document_is_pdf(document: &Document) -> bool {
document
.content_type
.as_deref()
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
})
}
fn expected_asset_cardinality(document: &Document, version: &DocumentVersion) -> i32 {
if let Value::Object(map) = &version.metadata {
if let Some(count) = map.get("page_count").and_then(|v| v.as_i64()) {
if count > 0 {
return count
.min(i64::from(i32::MAX))
.try_into()
.unwrap_or(i32::MAX);
}
}
}
if document_is_pdf(document) {
1
} else {
1
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ Document Assets
--------------- ---------------
- GET /api/documents/:id/assets - List generated assets for the current version. - GET /api/documents/:id/assets - List generated assets for the current version.
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag. - POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
- GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object). - GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
Downloads Downloads
--------- ---------
+1 -1
View File
@@ -44,4 +44,4 @@ npm run build
## Assets ## Assets
- The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/). - The folder icon (`public/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
-727
View File
@@ -18,7 +18,6 @@
"@babel/core": "7.26.0", "@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"css-loader": "7.1.2", "css-loader": "7.1.2",
"dotenv": "16.4.5", "dotenv": "16.4.5",
@@ -642,22 +641,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-syntax-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-unicode-sets-regex": { "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
"version": "7.18.6", "version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
@@ -1317,22 +1300,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-react-constant-elements": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz",
"integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-display-name": { "node_modules/@babel/plugin-transform-react-display-name": {
"version": "7.28.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
@@ -1532,26 +1499,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/plugin-transform-typescript": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.27.3",
"@babel/helper-create-class-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
"@babel/plugin-syntax-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-unicode-escapes": { "node_modules/@babel/plugin-transform-unicode-escapes": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
@@ -1749,26 +1696,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/preset-typescript": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-validator-option": "^7.27.1",
"@babel/plugin-syntax-jsx": "^7.27.1",
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
"@babel/plugin-transform-typescript": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2037,290 +1964,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-plugin-transform-svg-component": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/babel-preset": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@svgr/core": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"camelcase": "^6.2.0",
"cosmiconfig": "^8.1.3",
"snake-case": "^3.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/hast-util-to-babel-ast": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.21.3",
"entities": "^4.4.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@svgr/plugin-jsx": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
"@svgr/hast-util-to-babel-ast": "8.0.0",
"svg-parser": "^2.0.4"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svgr/plugin-svgo": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz",
"integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cosmiconfig": "^8.1.3",
"deepmerge": "^4.3.1",
"svgo": "^3.0.2"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
},
"peerDependencies": {
"@svgr/core": "*"
}
},
"node_modules/@svgr/webpack": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz",
"integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.21.3",
"@babel/plugin-transform-react-constant-elements": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.21.0",
"@svgr/core": "8.1.0",
"@svgr/plugin-jsx": "8.1.0",
"@svgr/plugin-svgo": "8.1.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@tabler/icons": { "node_modules/@tabler/icons": {
"version": "3.11.0", "version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
@@ -2347,16 +1990,6 @@
"react": ">= 16" "react": ">= 16"
} }
}, },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2962,13 +2595,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -3303,16 +2929,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camel-case": { "node_modules/camel-case": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
@@ -3324,19 +2940,6 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001749", "version": "1.0.30001749",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
@@ -3567,33 +3170,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"license": "MIT",
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0",
"path-type": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3662,20 +3238,6 @@
"url": "https://github.com/sponsors/fb55" "url": "https://github.com/sponsors/fb55"
} }
}, },
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": { "node_modules/css-what": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -3702,42 +3264,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csso": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "~2.2.0"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/css-tree": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.28",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/csso/node_modules/mdn-data": {
"version": "2.0.28",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3748,16 +3274,6 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/default-browser": { "node_modules/default-browser": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
@@ -4019,16 +3535,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4941,33 +4447,6 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/import-fresh/node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/import-local": { "node_modules/import-local": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -5015,13 +4494,6 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -5219,19 +4691,6 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5293,13 +4752,6 @@
"shell-quote": "^1.8.3" "shell-quote": "^1.8.3"
} }
}, },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/loader-runner": { "node_modules/loader-runner": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
@@ -5382,13 +4834,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -5738,38 +5183,6 @@
"tslib": "^2.0.3" "tslib": "^2.0.3"
} }
}, },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5825,16 +5238,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6714,17 +6117,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dot-case": "^3.0.4",
"tslib": "^2.0.3"
}
},
"node_modules/sockjs": { "node_modules/sockjs": {
"version": "0.3.24", "version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -6929,125 +6321,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svg-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/svgo": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@trysound/sax": "0.2.0",
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.0.0"
},
"bin": {
"svgo": "bin/svgo"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/svgo"
}
},
"node_modules/svgo/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/svgo/node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/svgo/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/svgo/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/svgo/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/svgo/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+1 -2
View File
@@ -26,7 +26,6 @@
"style-loader": "4.0.0", "style-loader": "4.0.0",
"webpack": "5.95.0", "webpack": "5.95.0",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0", "webpack-dev-server": "5.1.0"
"@svgr/webpack": "8.1.0"
} }
} }
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979"> <linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
<stop offset="0" stop-color="#62a0ea"/> <stop offset="0" stop-color="#62a0ea"/>
<stop offset="0.0576991" stop-color="#afd4ff"/> <stop offset="0.0576991" stop-color="#afd4ff"/>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

-12
View File
@@ -1,12 +0,0 @@
import React from 'react';
export const AppShellContext = React.createContext(null);
export const useAppShell = () => {
const context = React.useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
+21 -214
View File
@@ -17,116 +17,7 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
return getAssetFromGroup(currentVersion.assets, assetType); return getAssetFromGroup(currentVersion.assets, assetType);
}; };
const normalizeAssetObjects = (objects) => { export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => {
if (!Array.isArray(objects)) {
return [];
}
return objects
.filter((entry) => Number.isInteger(entry?.ordinal))
.slice()
.sort((a, b) => a.ordinal - b.ordinal);
};
const mergeAssetObjects = (existingObjects, incomingObjects) => {
const merged = new Map();
normalizeAssetObjects(existingObjects).forEach((entry) => {
merged.set(entry.ordinal, { ...entry });
});
normalizeAssetObjects(incomingObjects).forEach((entry) => {
const current = merged.get(entry.ordinal) || {};
merged.set(entry.ordinal, { ...current, ...entry });
});
return [...merged.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, value]) => value);
};
export class AssetView {
constructor(asset) {
this.asset = asset || null;
this._objectsRef = null;
this._sortedObjects = [];
}
getCardinality() {
if (!this.asset) {
return 0;
}
const reported = Number(this.asset.cardinality);
if (Number.isFinite(reported) && reported > 0) {
return reported;
}
const objectsCount = this.getObjects().length;
if (objectsCount > 0) {
return objectsCount;
}
return this.asset.metadata ? 1 : 0;
}
getObjects() {
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
return [];
}
if (this._objectsRef === this.asset.objects) {
return this._sortedObjects;
}
this._objectsRef = this.asset.objects;
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
return this._sortedObjects;
}
getObject(ordinal = 1) {
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
if (fromObjects) {
return fromObjects;
}
if (ordinal === 1 && this.asset) {
if (this.asset.url || this.asset.metadata) {
return {
ordinal: 1,
url: this.asset.url || null,
metadata: this.asset.metadata || null,
expires_at: this.asset.expiresAt ?? null,
};
}
}
return null;
}
getPrimaryObject() {
return this.getObject(1);
}
getPrimaryMetadata() {
return this.getPrimaryObject()?.metadata || null;
}
getPrimaryUrl() {
return this.getPrimaryObject()?.url || null;
}
hasObject(ordinal) {
return Boolean(this.getObject(ordinal));
}
}
export const createAssetView = (asset) => new AssetView(asset);
export const resolveDocumentAssetUrl = (
doc,
type,
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
) => {
if (!doc || !type) { if (!doc || !type) {
return null; return null;
} }
@@ -134,32 +25,15 @@ export const resolveDocumentAssetUrl = (
if (!asset) { if (!asset) {
return null; return null;
} }
const view = createAssetView(asset);
const object = view.getObject(objectOrdinal);
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
const expiresAt = typeof object?.expires_at === 'number'
? object.expires_at
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
? asset.expiresAt
: null;
const now = Date.now(); const now = Date.now();
if (url && (!expiresAt || expiresAt > now)) { const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
return url; const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
if (hasFreshUrl) {
return asset.url;
} }
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') { if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
const force = Boolean(url && expiresAt && expiresAt <= now); const force = Boolean(asset.url && expiresAt && expiresAt <= now);
const options = { const options = ensureOptions ? { ...ensureOptions, force } : { force };
force,
start: objectOrdinal,
limit: 1,
...(ensureOptions || {}),
};
if (!options.start) {
options.start = objectOrdinal;
}
if (!options.limit) {
options.limit = 1;
}
ensureAssetUrl(doc.id, asset, options).catch(() => {}); ensureAssetUrl(doc.id, asset, options).catch(() => {});
} }
return null; return null;
@@ -189,10 +63,6 @@ class AssetManager {
} }
const cached = this.assetCache.get(asset.id); const cached = this.assetCache.get(asset.id);
if (!cached) { if (!cached) {
const normalized = mergeAssetObjects(null, asset.objects);
if (normalized.length) {
return { ...asset, objects: normalized };
}
return asset; return asset;
} }
const merged = { ...cached, ...asset }; const merged = { ...cached, ...asset };
@@ -206,10 +76,6 @@ class AssetManager {
merged.expiresAt = cachedExpires; merged.expiresAt = cachedExpires;
} }
} }
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
if (mergedObjects.length) {
merged.objects = mergedObjects;
}
return merged; return merged;
} }
@@ -306,53 +172,24 @@ class AssetManager {
return next; return next;
} }
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) { ensureAsset(documentId, asset, { force = false } = {}) {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return Promise.resolve(asset || null); return Promise.resolve(asset || null);
} }
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1; const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1; if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
const requestedEnd = requestedStart + requestedLimit - 1; this.rememberAsset(asset);
return Promise.resolve(asset);
}
const baseAsset = this.assetCache.get(asset.id) || asset; const cached = this.assetCache.get(asset.id);
const view = createAssetView(baseAsset);
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
const now = Date.now(); const now = Date.now();
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
const isOrdinalSatisfied = (ordinal) => { return Promise.resolve({ ...asset, ...cached });
const object = view.getObject(ordinal);
if (!object) {
return false;
}
if (!object.url) {
return false;
}
if (typeof object.expires_at === 'number') {
return object.expires_at > now;
}
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
return true;
}
return true;
};
let needsFetch = force;
if (!needsFetch) {
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
if (!isOrdinalSatisfied(ordinal)) {
needsFetch = true;
break;
}
}
} }
if (!needsFetch) { const inflightKey = `${documentId}:${asset.id}`;
this.rememberAsset(baseAsset);
return Promise.resolve(baseAsset);
}
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
if (!force && this.assetInflight.has(inflightKey)) { if (!force && this.assetInflight.has(inflightKey)) {
return this.assetInflight.get(inflightKey); return this.assetInflight.get(inflightKey);
} }
@@ -361,44 +198,14 @@ class AssetManager {
return Promise.reject(new Error('AssetManager API client is not configured.')); return Promise.reject(new Error('AssetManager API client is not configured.'));
} }
const params = {};
if (Number.isInteger(start) && start > 0) {
params.start = start;
}
if (Number.isInteger(limit) && limit > 0) {
params.limit = limit;
}
const requestConfig = Object.keys(params).length ? { params } : undefined;
const request = this.api const request = this.api
.get(`/assets/${asset.id}`, requestConfig) .get(`/documents/${documentId}/assets/${asset.id}`)
.then(({ data }) => { .then(({ data }) => {
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
const view = createAssetView(combined);
const primaryObject = view.getPrimaryObject();
const expiresAt = typeof primaryObject?.expires_at === 'number'
? primaryObject.expires_at
: Date.now() + this.assetPresignTtlMs;
const cardinality = (() => {
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
const objectsCount = mergedObjects.length;
if (Number.isFinite(reported) && reported > 0) {
return Math.max(reported, objectsCount) || null;
}
return objectsCount || null;
})();
const entry = { const entry = {
...combined, ...asset,
cardinality, ...data,
url: view.getPrimaryUrl(), expiresAt: Date.now() + this.assetPresignTtlMs,
expiresAt,
}; };
this.rememberAsset(entry); this.rememberAsset(entry);
return entry; return entry;
}) })
-19
View File
@@ -1,19 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 128 128" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<defs>
<filter id="cornerShadow" x="-20%" y="-20%" width="200%" height="200%">
<feDropShadow dx="-4" dy="4" stdDeviation="8" flood-opacity="0.24"/>
</filter>
<linearGradient id="cornerGradient" gradientUnits="userSpaceOnUse" x1="32" y1="0" x2="0" y2="64">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#f3f3f3"/>
</linearGradient>
</defs>
<g filter="url(#cornerShadow)">
<g transform="matrix(-1.81626,-1.81626,0.460023,-0.460023,180.663,203.387)">
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="url(#cornerGradient)"/>
</g>
<g transform="matrix(1.81626,1.81626,-0.460023,0.460023,4.6259,-132.676)">
<path d="M70.488,10.11L89.954,86.966L51.022,86.966L70.488,10.11Z" fill="#fdfdfd"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+54 -225
View File
@@ -1,25 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons'; import { DownloadIcon, EditIcon } from '../ui/icons';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator';
import { CORRESPONDENT_ROLES } from '../constants/correspondents'; import { CORRESPONDENT_ROLES } from '../constants/correspondents';
const MAX_PREVIEW_STACK_ITEMS = 15; const MAX_PREVIEW_STACK_ITEMS = 15;
const normalizeRole = (role) => (role || '').toLowerCase(); const normalizeRole = (role) => (role || '').toLowerCase();
const derivePreviewOrientation = (metadata) => {
const width = Number(metadata?.width);
const height = Number(metadata?.height);
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
return width >= height ? 'landscape' : 'portrait';
}
return 'landscape';
};
const formatRoleLabel = (role) => { const formatRoleLabel = (role) => {
const normalized = normalizeRole(role); const normalized = normalizeRole(role);
if (!normalized) return 'Other'; if (!normalized) return 'Other';
@@ -445,34 +435,20 @@ const DetailPanel = ({
setOcrOpen(false); setOcrOpen(false);
}, []); }, []);
const singlePreviewNavigator = useAssetNavigator({
document: singleDoc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const makePreviewItem = useCallback( const makePreviewItem = useCallback(
(doc, ordinal = 1) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = getDocumentAsset(doc, 'preview'); const url = resolveDocumentAssetUrl(doc, 'preview', {
const assetView = createAssetView(asset); ensureAssetUrl,
const object = assetView.getObject(ordinal); getAsset: getDocumentAsset,
let url = object?.url || null; });
if (!url) {
url = resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
ensureOptions: { start: ordinal, limit: 1 },
objectOrdinal: ordinal,
});
}
if (!url) { if (!url) {
return null; return null;
} }
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {}; const asset = getDocumentAsset(doc, 'preview');
const orientation = derivePreviewOrientation(metadata); const width = Number(asset?.metadata?.width) || 0;
const height = Number(asset?.metadata?.height) || 0;
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
return { return {
id: doc.id, id: doc.id,
url, url,
@@ -499,74 +475,32 @@ const DetailPanel = ({
return ordered; return ordered;
}, [selectedDocuments]); }, [selectedDocuments]);
const stackTopDocument = stackDocuments[0] || null;
const stackPreviewNavigator = useAssetNavigator({
document: stackTopDocument,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch: 3,
});
const singlePreviewItems = useMemo(() => { const singlePreviewItems = useMemo(() => {
if (!singleDoc) return []; if (!singleDoc) return [];
const url = singlePreviewNavigator.currentUrl; const item = makePreviewItem(singleDoc);
if (!url) { return item ? [item] : [];
return []; }, [singleDoc, makePreviewItem]);
const stackPreviews = useMemo(
() =>
stackDocuments
.map((doc) => makePreviewItem(doc))
.filter(Boolean),
[stackDocuments, makePreviewItem],
);
useEffect(() => {
if (!ensureAssetUrl) {
return;
} }
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
return [
{
id: singleDoc.id,
url,
orientation,
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
},
];
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
const stackPreviews = useMemo(() => { stackDocuments.forEach((doc) => {
if (!stackDocuments.length) { resolveDocumentAssetUrl(doc, 'preview', {
return []; ensureAssetUrl,
} getAsset: getDocumentAsset,
return stackDocuments });
.map((doc) => { });
if (!doc) return null; }, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
if (stackTopDocument && doc.id === stackTopDocument.id) {
const url = stackPreviewNavigator.currentUrl;
if (!url) {
return null;
}
const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata);
return {
id: doc.id,
url,
orientation,
alt: doc.title || doc.original_name || 'Document preview',
};
}
return makePreviewItem(doc, 1);
})
.filter(Boolean);
}, [
stackDocuments,
stackTopDocument,
stackPreviewNavigator.currentUrl,
stackPreviewNavigator.currentMetadata,
makePreviewItem,
]);
const singleCardinality = singlePreviewNavigator.cardinality;
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
const topDocId = stackTopDocument?.id || null;
const topCardinality = stackPreviewNavigator.cardinality;
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topOrdinal = stackPreviewNavigator.ordinal;
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const bulkTagUnion = useMemo(() => { const bulkTagUnion = useMemo(() => {
if (!selectedDocuments.length) return []; if (!selectedDocuments.length) return [];
@@ -715,74 +649,21 @@ const DetailPanel = ({
? new Date(singleDoc.issued_at).toLocaleString() ? new Date(singleDoc.issued_at).toLocaleString()
: '—'; : '—';
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : []; const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const pageCountRaw = singleDoc.current_version?.metadata?.page_count;
const pageCountValue =
typeof pageCountRaw === 'number'
? pageCountRaw
: pageCountRaw != null && pageCountRaw !== ''
? Number.parseInt(pageCountRaw, 10)
: null;
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
const metadata = const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null; singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
const effectiveCardinality = singleEffectiveCardinality;
const canGoPrev = singlePreviewNavigator.canGoPrev;
const canGoNext = singlePreviewNavigator.canGoNext;
const hasPreviewImage = singleHasPreview;
const interceptNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div className="preview-pane preview-pane--stack"> <div>
<PreviewStack <div className="preview-pane preview-pane--stack">
items={singlePreviewItems} <PreviewStack
maxItems={1} items={singlePreviewItems}
emptyMessage="Preview loading…" maxItems={1}
onItemActivate={handlePreviewActivate} emptyMessage="Preview loading…"
onOpenPreview={onOpenPreview} onItemActivate={handlePreviewActivate}
activeItemId={activePreviewId} onOpenPreview={onOpenPreview}
/> activeItemId={activePreviewId}
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? ( />
<div className="preview-pane__nav preview-pane__nav--overlay"> </div>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goPrev();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
singlePreviewNavigator.goNext();
}}
onPointerDown={interceptNavPointer}
onPointerUp={interceptNavPointer}
onMouseDown={interceptNavPointer}
onMouseUp={interceptNavPointer}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div> </div>
<div className="doc-title-row"> <div className="doc-title-row">
{isEditingTitle ? ( {isEditingTitle ? (
@@ -847,11 +728,6 @@ const DetailPanel = ({
<div> <div>
<strong>Issued:</strong> {issuedAt} <strong>Issued:</strong> {issuedAt}
</div> </div>
{hasPageCount ? (
<div>
<strong>Pages:</strong> {pageCountValue}
</div>
) : null}
<div> <div>
<strong>Original filename:</strong>{' '} <strong>Original filename:</strong>{' '}
{singleDoc.original_name} {singleDoc.original_name}
@@ -982,66 +858,19 @@ const DetailPanel = ({
const renderBulk = () => { const renderBulk = () => {
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`; const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—'; const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
const topDoc = stackTopDocument;
const topDocIdLocal = topDocId;
const topOrdinal = stackPreviewNavigator.ordinal;
const topCardinalityLocal = topEffectiveCardinality;
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
const topCanGoNext = stackPreviewNavigator.canGoNext;
const interceptTopNavPointer = (event) => {
event.preventDefault();
event.stopPropagation();
};
return ( return (
<> <>
<div className="preview-pane preview-pane--stack"> <div>
<PreviewStack <div className="preview-pane preview-pane--stack">
items={stackPreviews} <PreviewStack
emptyMessage="No previews available." items={stackPreviews}
onItemActivate={handlePreviewActivate} emptyMessage="No previews available."
onOpenPreview={onOpenPreview} onItemActivate={handlePreviewActivate}
activeItemId={activePreviewId} onOpenPreview={onOpenPreview}
/> activeItemId={activePreviewId}
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? ( />
<div className="preview-pane__nav preview-pane__nav--overlay"> </div>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--prev"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goPrev();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="preview-pane__nav-button preview-pane__nav-button--next"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
stackPreviewNavigator.goNext();
}}
onPointerDown={interceptTopNavPointer}
onPointerUp={interceptTopNavPointer}
onMouseDown={interceptTopNavPointer}
onMouseUp={interceptTopNavPointer}
disabled={!topCanGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div> </div>
<h3 style={{ margin: 0 }}>{countLabel}</h3> <h3 style={{ margin: 0 }}>{countLabel}</h3>
<div className="meta"> <div className="meta">
+34 -91
View File
@@ -1,50 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons'; import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag']; const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
const DEFAULT_GRID_ICON_SIZE = 96;
const DEFAULT_GRID_TITLE_SIZE = '11px';
const LIST_ICON_SIZE = 48;
const getPageCount = (doc) => const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
Number.isFinite(doc?.current_version?.metadata?.page_count)
? doc.current_version.metadata.page_count
: null;
const DocumentThumbnailImage = ({
document,
ensureAssetUrl,
getDocumentAsset,
alt,
maxSize = LIST_ICON_SIZE,
}) => {
const resolvedMaxSize = Math.max(1, Math.round(maxSize || 1));
const thumbnailAsset = useMemo(
() => getAssetFromVersion(document?.current_version, 'thumbnail'),
[document?.current_version],
);
const thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
const assetWidth = Number(primaryMetadata?.width);
const assetHeight = Number(primaryMetadata?.height);
const dimensions = useMemo(() => {
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
return { width: resolvedMaxSize, height: resolvedMaxSize };
}
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
return {
width: Math.max(1, Math.round(assetWidth * scale)),
height: Math.max(1, Math.round(assetHeight * scale)),
};
}, [assetWidth, assetHeight, resolvedMaxSize]);
const innerStyle = useMemo(
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
[dimensions.height, dimensions.width],
);
const url = useMemo( const url = useMemo(
() => () =>
resolveDocumentAssetUrl(document, 'thumbnail', { resolveDocumentAssetUrl(document, 'thumbnail', {
@@ -54,28 +17,19 @@ const DocumentThumbnailImage = ({
[document, ensureAssetUrl, getDocumentAsset], [document, ensureAssetUrl, getDocumentAsset],
); );
const pageCount = getPageCount(document);
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
const innerClasses = ['document-thumbnail-inner'];
if (showMultiPageBadge) {
innerClasses.push('document-thumbnail-inner--multipage');
}
return ( return (
<div className="document-thumbnail-wrapper"> <div className="document-thumbnail-wrapper">
<div className={innerClasses.join(' ')} style={innerStyle}> {url ? (
{url ? ( <img
<img src={url}
src={url} alt={alt || ''}
alt={alt || ''} className="document-thumbnail"
className="document-thumbnail" draggable={false}
draggable={false} onDragStart={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()} />
/> ) : (
) : ( <div className="thumb-placeholder">DOC</div>
<div className="thumb-placeholder">DOC</div> )}
)}
</div>
</div> </div>
); );
}; };
@@ -143,7 +97,6 @@ const DocumentsTable = ({
); );
const scrollRef = useRef(null); const scrollRef = useRef(null);
const isGridView = viewMode === 'grid'; const isGridView = viewMode === 'grid';
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback( const handleSetViewMode = useCallback(
(nextMode) => { (nextMode) => {
if (!onViewModeChange) { if (!onViewModeChange) {
@@ -273,13 +226,6 @@ const DocumentsTable = ({
[onClearSelection], [onClearSelection],
); );
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
const showListSearchEmptyState =
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
const showGridSearchEmptyState =
isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading;
const showSearchHint = showingSearchResults && rows.length > 0;
return ( return (
<section <section
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`} className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
@@ -349,16 +295,6 @@ const DocumentsTable = ({
</button> </button>
</div> </div>
</div> </div>
{showDefaultEmptyState && (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
)}
{showGridSearchEmptyState && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
<div className="column-body"> <div className="column-body">
<div <div
ref={scrollRef} ref={scrollRef}
@@ -379,12 +315,15 @@ const DocumentsTable = ({
}} }}
aria-activedescendant={isGridView ? undefined : activeDescendantId} aria-activedescendant={isGridView ? undefined : activeDescendantId}
> >
{showDefaultEmptyState ? null : isGridView ? ( {!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
) : isGridView ? (
<div <div
className="documents-grid" className="documents-grid"
role="list" role="list"
onClick={handleGridBackgroundClick} onClick={handleGridBackgroundClick}
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
> >
{!showingSearchResults && {!showingSearchResults &&
subfolders.map((folder) => { subfolders.map((folder) => {
@@ -431,9 +370,10 @@ const DocumentsTable = ({
}} }}
> >
<div className="folder-card__icon"> <div className="folder-card__icon">
<FolderIcon <img
src={FOLDER_ICON_SRC}
alt="Folder"
className="folder-card__icon-svg" className="folder-card__icon-svg"
size={gridIconSize}
/> />
</div> </div>
<div className="folder-card__meta"> <div className="folder-card__meta">
@@ -459,7 +399,6 @@ const DocumentsTable = ({
className={cardClasses.join(' ')} className={cardClasses.join(' ')}
role="listitem" role="listitem"
id={`document-card-${doc.id}`} id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
@@ -477,7 +416,6 @@ const DocumentsTable = ({
ensureAssetUrl={ensureAssetUrl} ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset} getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`} alt={`Thumbnail for ${doc.title || doc.original_name}`}
maxSize={gridIconSize}
/> />
<div className="document-card__meta"> <div className="document-card__meta">
<div <div
@@ -612,9 +550,10 @@ const DocumentsTable = ({
> >
<td className="thumb-cell"> <td className="thumb-cell">
<div className="thumb-icon"> <div className="thumb-icon">
<FolderIcon <img
src={FOLDER_ICON_SRC}
alt="Folder"
className="thumb-icon__image" className="thumb-icon__image"
size={32}
/> />
</div> </div>
</td> </td>
@@ -676,7 +615,6 @@ const DocumentsTable = ({
key={doc.id} key={doc.id}
className={rowClasses.join(' ')} className={rowClasses.join(' ')}
id={`document-row-${doc.id}`} id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)} onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)} onDoubleClick={() => onDocumentOpen(doc.id)}
draggable draggable
@@ -826,15 +764,20 @@ const DocumentsTable = ({
</table> </table>
)} )}
</div> </div>
{showListSearchEmptyState && ( {rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
<div className="empty-state">No documents match the current filters.</div> <div className="empty-state">No documents match the current filters.</div>
)} )}
{showSearchHint && ( {showingSearchResults && rows.length > 0 && (
<div className="search-hint"> <div className="search-hint">
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
</div> </div>
)} )}
</div> </div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section> </section>
); );
}; };
-133
View File
@@ -1,133 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createAssetView } from '../asset_manager';
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
const raw = Number.isFinite(value) ? value : defaultOrdinal;
let next = Math.max(1, Math.floor(raw));
if (cardinality && cardinality > 0) {
next = Math.min(next, cardinality);
}
return next;
};
export const useAssetNavigator = ({
document,
assetType,
ensureAssetUrl,
getAsset,
prefetch = 2,
defaultOrdinal = 1,
}) => {
const documentId = document?.id || null;
const asset = useMemo(() => {
if (!document || typeof getAsset !== 'function') {
return null;
}
return getAsset(document, assetType);
}, [document, assetType, getAsset]);
const view = useMemo(() => createAssetView(asset), [asset]);
const cardinality = view.getCardinality();
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
useEffect(() => {
setOrdinalInternal(defaultOrdinal);
}, [documentId, assetType, defaultOrdinal]);
const setOrdinal = useCallback(
(next) => {
setOrdinalInternal((prev) => {
const target = typeof next === 'function' ? next(prev) : next;
return clampOrdinalValue(target, cardinality, defaultOrdinal);
});
},
[cardinality, defaultOrdinal],
);
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
const objects = view.getObjects();
const currentObject = view.getObject(ordinal);
const currentUrl = currentObject?.url || view.getPrimaryUrl();
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
const canGoPrev = ordinal > 1;
const canGoNext = cardinality ? ordinal < cardinality : true;
const ordinalsNeedingLoad = useMemo(() => {
const missing = [];
if (!asset) {
return missing;
}
const maxOrdinal = cardinality && cardinality > 0
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
: ordinal + Math.max(1, prefetch) - 1;
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
const object = view.getObject(ord);
if (!object?.url) {
missing.push(ord);
}
}
return missing;
}, [asset, view, ordinal, prefetch, cardinality]);
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!documentId || !asset || !ensureAssetUrl) {
setLoading(false);
return;
}
if (!fetchStart || !fetchLimit) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
ensureAssetUrl(documentId, asset, {
start: fetchStart,
limit: fetchLimit,
})
.catch(() => {})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
return {
document,
documentId,
asset,
assetType,
ordinal,
setOrdinal,
goPrev,
goNext,
canGoPrev,
canGoNext,
cardinality,
currentObject,
currentUrl,
currentMetadata,
objects,
isLoading: loading,
};
};
export default useAssetNavigator;
+271 -144
View File
@@ -1,10 +1,10 @@
import React, { import React, {
useCallback, useCallback,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useContext,
useReducer, useReducer,
} from 'react'; } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
@@ -21,18 +21,18 @@ import {
matchPath, matchPath,
} from 'react-router-dom'; } from 'react-router-dom';
import './styles.css'; import './styles.css';
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
import useApiError from './hooks/useApiError'; import useApiError from './hooks/useApiError';
import SkeuomorphicWorkspace from './skeuomorphic_ws'; import SkeuomorphicWorkspace from './skeuomorphic_ws';
import DetailPanel from './detail/DetailPanel'; import DetailPanel from './detail/DetailPanel';
import TagsPanel from './tags/TagsPanel'; import TagsPanel from './tags/TagsPanel';
import CorrespondentsPanel from './correspondents/CorrespondentsPanel'; import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
import { CORRESPONDENT_ROLES } from './constants/correspondents'; import { CORRESPONDENT_ROLES } from './constants/correspondents';
import { DownloadIcon } from './ui/icons';
import TagManager from './tag_manager'; import TagManager from './tag_manager';
import { formatFileSize } from './utils/format';
import Sidebar from './sidebar/Sidebar'; import Sidebar from './sidebar/Sidebar';
import DocumentsTable from './documents/DocumentsTable'; import DocumentsTable from './documents/DocumentsTable';
import { AppShellContext, useAppShell } from './appShellContext';
import DocumentViewerRoute from './routes/DocumentViewerRoute';
const runtimeApiBase = const runtimeApiBase =
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
@@ -226,6 +226,91 @@ const LoginView = ({ onSubmit, status }) => (
const PreviewWorkspace = ({
document,
previewEntry,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
const DocumentsLayout = ({ sidebarProps, children }) => ( const DocumentsLayout = ({ sidebarProps, children }) => (
<main className="documents-main"> <main className="documents-main">
<Sidebar {...sidebarProps} /> <Sidebar {...sidebarProps} />
@@ -233,16 +318,19 @@ const DocumentsLayout = ({ sidebarProps, children }) => (
</main> </main>
); );
const AppShellContext = React.createContext(null);
const AppLayout = () => { const AppLayout = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const appState = useAppState(); const appState = useAppState();
const appDispatch = useAppDispatch(); const appDispatch = useAppDispatch();
const folderMatch = matchPath('/documents/folder/:folderId', location.pathname); const folderMatch = matchPath('/documents/folder/:folderId', location.pathname);
const docMatch = matchPath('/documents/:documentId', location.pathname); const nestedDocMatch = matchPath('/documents/folder/:folderId/documents/:documentId', location.pathname);
const routeFolderId = folderMatch?.params?.folderId || null; const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
const routeFolderId =
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null; const routeDocumentId = docMatch?.params?.documentId || null;
const previewDocumentId = routeDocumentId;
const { status: appStatus, token } = appState; const { status: appStatus, token } = appState;
const [status, setStatus] = useState(null); const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => { const setStatusMessage = useCallback((message, variant = 'info') => {
@@ -387,6 +475,8 @@ const AppLayout = () => {
folderName: DEFAULT_FOLDER_NAME, folderName: DEFAULT_FOLDER_NAME,
}); });
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null); const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
const [previewDocumentId, setPreviewDocumentId] = useState(null);
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const shellRef = useRef(null); const shellRef = useRef(null);
const assetManagerRef = useRef(null); const assetManagerRef = useRef(null);
@@ -413,37 +503,14 @@ const AppLayout = () => {
const isAssetEquivalent = (lhs, rhs) => { const isAssetEquivalent = (lhs, rhs) => {
if (!lhs || !rhs) return false; if (!lhs || !rhs) return false;
const lhsView = createAssetView(lhs);
const rhsView = createAssetView(rhs);
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
const lhsObjects = lhsView.getObjects();
const rhsObjects = rhsView.getObjects();
const objectsComparable = lhsObjects.length === rhsObjects.length
&& lhsObjects.every((entry, index) => {
const other = rhsObjects[index];
if (!other) return false;
if (entry.ordinal !== other.ordinal) return false;
if (entry.url && other.url && entry.url === other.url) {
return true;
}
if (!entry.url && !other.url) {
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
}
return entry.url === other.url;
});
return ( return (
lhs.id === rhs.id && lhs.id === rhs.id &&
lhs.url === rhs.url && lhs.url === rhs.url &&
lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width && lhs?.metadata?.width === rhs?.metadata?.width &&
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhs?.metadata?.height === rhs?.metadata?.height &&
lhs.mime_type === rhs.mime_type && lhs.mime_type === rhs.mime_type &&
lhs.asset_type === rhs.asset_type && lhs.asset_type === rhs.asset_type &&
lhs.created_at === rhs.created_at && lhs.created_at === rhs.created_at
lhsCardinality === rhsCardinality &&
objectsComparable
); );
}; };
@@ -541,6 +608,8 @@ const AppLayout = () => {
setActiveTagFilters([]); setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setActivePreviewId(null); setActivePreviewId(null);
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
assetManager.reset(); assetManager.reset();
setPreviewEntries(() => new Map()); setPreviewEntries(() => new Map());
previewInflightRef.current = new Map(); previewInflightRef.current = new Map();
@@ -685,6 +754,9 @@ const AppLayout = () => {
useEffect(() => { useEffect(() => {
if (!selectedDocumentIds.length) { if (!selectedDocumentIds.length) {
if (activePreviewId !== null) {
setActivePreviewId(null);
}
return; return;
} }
if (!selectedDocumentIds.includes(activePreviewId)) { if (!selectedDocumentIds.includes(activePreviewId)) {
@@ -2375,17 +2447,13 @@ const AppLayout = () => {
); );
const ensureAssetUrl = useCallback( const ensureAssetUrl = useCallback(
async (documentId, asset, { force = false, start = null, limit = null } = {}) => { async (documentId, asset, { force = false } = {}) => {
if (!documentId || !asset?.id) { if (!documentId || !asset?.id) {
return null; return null;
} }
try { try {
const entry = await assetManager.ensureAsset(documentId, asset, { const entry = await assetManager.ensureAsset(documentId, asset, { force });
force,
start,
limit,
});
if (!entry) { if (!entry) {
return null; return null;
@@ -3016,75 +3084,63 @@ const AppLayout = () => {
[token, refreshCurrentFolder, notifyApiError, setStatusMessage], [token, refreshCurrentFolder, notifyApiError, setStatusMessage],
); );
const ensurePreviewData = useCallback( const openDocumentPreview = useCallback(
async (documentId) => { async (documentId, { replace = false, skipNavigate = false } = {}) => {
if (!documentId) return null; if (!documentId) return;
setPreviewDocumentId(documentId);
const findInCache = () => { setPreviewDocumentLoading(true);
try {
const pool = searchResults ?? documents; const pool = searchResults ?? documents;
return pool.find((item) => item.id === documentId) || null; const doc = pool.find((item) => item.id === documentId);
};
let doc = findInCache();
if (!doc) {
const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
if (!doc) { if (!doc) {
throw new Error('Document metadata unavailable.'); throw new Error('Document metadata unavailable.');
} }
setDocuments((prev) => { const currentVersion = doc.current_version || null;
if (prev.some((item) => item.id === doc.id)) { const previewAsset = getAssetFromVersion(currentVersion, 'preview');
return prev; const thumbnailAsset = getAssetFromVersion(currentVersion, 'thumbnail');
const refreshAssetIfNeeded = async (asset) => {
if (!asset?.id) {
return;
} }
return [doc, ...prev]; const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
}); const shouldForce = Boolean(asset.url && expiresAt && expiresAt <= Date.now());
} if (!asset.url || shouldForce) {
try {
await ensureAssetUrl(documentId, asset, { force: shouldForce || !asset.url });
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
};
const targetFolder = doc?.folder_id || selectedFolder || 'root'; await refreshAssetIfNeeded(previewAsset);
if (targetFolder && targetFolder !== selectedFolder) { refreshAssetIfNeeded(thumbnailAsset);
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
doc = findInCache() || doc;
}
await ensurePreviewUrl(documentId, { force: false }); await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId); setActivePreviewId(documentId);
return doc; if (!skipNavigate && navigate) {
navigate(`/documents/${documentId}`, { replace });
}
} catch (error) {
notifyApiError(error, 'Failed to open document preview.');
setPreviewDocumentId(null);
} finally {
setPreviewDocumentLoading(false);
}
}, },
[ [
searchResults,
documents, documents,
api, searchResults,
assetManager,
setDocuments,
selectedFolder,
loadFolder,
isFilterActive,
ensurePreviewUrl, ensurePreviewUrl,
setActivePreviewId, ensureAssetUrl,
navigate,
notifyApiError,
], ],
); );
const openDocumentPreview = useCallback(
(documentId, { replace = false } = {}) => {
if (!documentId) return;
navigate(`/documents/${documentId}`, { replace });
},
[navigate],
);
const closeDocumentPreview = useCallback(
(folderId = null) => {
const targetId = folderId || selectedFolder || 'root';
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace: false });
},
[navigate, selectedFolder],
);
const handleDocumentListFocus = useCallback(() => { const handleDocumentListFocus = useCallback(() => {
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) { if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
return; return;
@@ -3213,6 +3269,24 @@ const AppLayout = () => {
], ],
); );
const closeDocumentPreview = useCallback(
(folderId = null) => {
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
const targetId = folderId || selectedFolder || 'root';
if (!navigate) {
loadFolder(targetId, { showLoading: false, preserveSearch: isFilterActive });
return;
}
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
navigate(path, { replace: false });
},
[navigate, selectedFolder, loadFolder, isFilterActive],
);
useEffect(() => { useEffect(() => {
if (!previewDocumentId) return; if (!previewDocumentId) return;
const handleKeyDown = (event) => { const handleKeyDown = (event) => {
@@ -3224,6 +3298,63 @@ const AppLayout = () => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [previewDocumentId, closeDocumentPreview]); }, [previewDocumentId, closeDocumentPreview]);
useEffect(() => {
if (!routeDocumentId) {
if (previewDocumentId) {
closeDocumentPreview();
}
return;
}
let cancelled = false;
const hydratePreview = async () => {
try {
const pool = searchResults ?? documents;
let doc = pool.find((item) => item.id === routeDocumentId) || null;
if (!doc) {
const { data } = await api.get(`/documents/${routeDocumentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || data.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
}
const targetFolder = doc?.folder_id || routeFolderId || 'root';
if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
}
if (!cancelled) {
await openDocumentPreview(routeDocumentId, { replace: true, skipNavigate: true });
}
} catch (error) {
if (!cancelled) {
notifyApiError(error, 'Failed to open document preview.');
}
}
};
hydratePreview();
return () => {
cancelled = true;
};
}, [
routeDocumentId,
routeFolderId,
api,
assetManager,
selectedFolder,
loadFolder,
openDocumentPreview,
previewDocumentId,
closeDocumentPreview,
documents,
searchResults,
notifyApiError,
isFilterActive,
]);
const handleDocumentTitleUpdate = useCallback( const handleDocumentTitleUpdate = useCallback(
async (documentId, nextTitle) => { async (documentId, nextTitle) => {
const trimmed = nextTitle.trim(); const trimmed = nextTitle.trim();
@@ -3327,38 +3458,13 @@ const AppLayout = () => {
); );
const handleDocumentTagAttach = useCallback( const handleDocumentTagAttach = useCallback(
async ({ documentId, tagId, tag: tagData = null }) => { async ({ documentId, tagId }) => {
if (!documentId || !tagId) { if (!documentId || !tagId) {
return false; return false;
} }
const resolveTagForCache = () => {
const lookupTag = tagLookupById.get(tagId);
const source = lookupTag || tagData;
if (!source) {
return { id: tagId, label: 'Tag', color: null };
}
return {
id: source.id ?? tagId,
label: source.label || source.name || 'Tag',
color: Object.prototype.hasOwnProperty.call(source, 'color')
? source.color
: null,
};
};
try { try {
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] }); await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
updateDocumentCaches(documentId, (doc) => {
if (!doc) {
return doc;
}
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.some((existing) => existing?.id === tagId)) {
return doc;
}
return { ...doc, tags: [...currentTags, resolveTagForCache()] };
});
setStatusMessage('Tag assigned.', 'success'); setStatusMessage('Tag assigned.', 'success');
await refreshCurrentFolder(); await refreshCurrentFolder();
return true; return true;
@@ -3368,14 +3474,7 @@ const AppLayout = () => {
return false; return false;
} }
}, },
[ [api, refreshCurrentFolder, notifyApiError, setStatusMessage],
api,
refreshCurrentFolder,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
tagLookupById,
],
); );
const handleDocumentTagDrop = useCallback( const handleDocumentTagDrop = useCallback(
@@ -3388,7 +3487,7 @@ const AppLayout = () => {
return; return;
} }
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag }); const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id });
if (!attached) { if (!attached) {
return; return;
} }
@@ -3853,14 +3952,14 @@ const AppLayout = () => {
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type)); return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
}; };
const isDocumentDropTarget = (target) => const isDocumentRowTarget = (target) =>
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false; target instanceof Element ? Boolean(target.closest('tr.document')) : false;
const handleTagDragOver = (event) => { const handleTagDragOver = (event) => {
if (!isTagTransfer(event)) { if (!isTagTransfer(event)) {
return; return;
} }
if (isDocumentDropTarget(event.target)) { if (isDocumentRowTarget(event.target)) {
setTagRemovalCursor(false); setTagRemovalCursor(false);
return; return;
} }
@@ -3875,7 +3974,7 @@ const AppLayout = () => {
} }
const related = event.relatedTarget; const related = event.relatedTarget;
if (related instanceof Element && host.contains(related)) { if (related instanceof Element && host.contains(related)) {
if (isDocumentDropTarget(related)) { if (isDocumentRowTarget(related)) {
setTagRemovalCursor(false); setTagRemovalCursor(false);
} }
return; return;
@@ -3888,7 +3987,7 @@ const AppLayout = () => {
return; return;
} }
setTagRemovalCursor(false); setTagRemovalCursor(false);
if (isDocumentDropTarget(event.target) || event.defaultPrevented) { if (isDocumentRowTarget(event.target) || event.defaultPrevented) {
return; return;
} }
event.preventDefault(); event.preventDefault();
@@ -4474,11 +4573,13 @@ const AppLayout = () => {
onRefresh: refreshCurrentFolder, onRefresh: refreshCurrentFolder,
onDocumentOpen: openDocumentPreview, onDocumentOpen: openDocumentPreview,
resolveThumbnailUrl: resolveThumbnailUrlForDoc, resolveThumbnailUrl: resolveThumbnailUrlForDoc,
availableTags: tags,
onCreateTag: handleTagCreate,
onAssignTagToDocument: handleDocumentTagAttach, onAssignTagToDocument: handleDocumentTagAttach,
onRemoveTagFromDocument: handleTagRemove, onRemoveTagFromDocument: handleTagRemove,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
activeTagIds: activeTagFilters, prepareTagPayload: buildTagPayload,
}), }),
[ [
documents, documents,
@@ -4489,11 +4590,13 @@ const AppLayout = () => {
refreshCurrentFolder, refreshCurrentFolder,
openDocumentPreview, openDocumentPreview,
resolveThumbnailUrlForDoc, resolveThumbnailUrlForDoc,
tags,
handleTagCreate,
handleDocumentTagAttach, handleDocumentTagAttach,
handleTagRemove, handleTagRemove,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
activeTagFilters, buildTagPayload,
], ],
); );
@@ -4532,9 +4635,6 @@ const AppLayout = () => {
showSkeuoWorkspace, showSkeuoWorkspace,
exitSkeuoWorkspace, exitSkeuoWorkspace,
skeuoWorkspaceProps, skeuoWorkspaceProps,
ensurePreviewData,
notifyApiError,
resolveApiPath,
}), }),
[ [
token, token,
@@ -4570,9 +4670,6 @@ const AppLayout = () => {
showSkeuoWorkspace, showSkeuoWorkspace,
exitSkeuoWorkspace, exitSkeuoWorkspace,
skeuoWorkspaceProps, skeuoWorkspaceProps,
ensurePreviewData,
notifyApiError,
resolveApiPath,
], ],
); );
@@ -4729,20 +4826,46 @@ const AppLayout = () => {
); );
}; };
const useAppShell = () => {
const context = useContext(AppShellContext);
if (!context) {
throw new Error('AppShellContext not found. Ensure routes are nested under AppLayout.');
}
return context;
};
const DocumentsRoute = () => { const DocumentsRoute = () => {
const { const {
sidebarProps, sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps, documentsTableProps,
detailPanelProps, detailPanelProps,
workspaceMode, workspaceMode,
skeuoWorkspaceProps, skeuoWorkspaceProps,
} = useAppShell(); } = useAppShell();
if (previewActive && previewWorkspaceDocument) {
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}
if (workspaceMode === 'skeuo') { if (workspaceMode === 'skeuo') {
return ( return (
<DocumentsLayout sidebarProps={sidebarProps}> <main className="skeuo-main">
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} /> <SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
</DocumentsLayout> </main>
); );
} }
@@ -4875,7 +4998,11 @@ const AppRouter = () => (
<Route path="/" element={<Navigate to="/documents" replace />} /> <Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} /> <Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} /> <Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentViewerRoute />} /> <Route
path="/documents/folder/:folderId/documents/:documentId"
element={<DocumentsRoute />}
/>
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="/tags" element={<TagsRoute />} /> <Route path="/tags" element={<TagsRoute />} />
<Route path="/correspondents" element={<CorrespondentsRoute />} /> <Route path="/correspondents" element={<CorrespondentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} /> <Route path="*" element={<Navigate to="/documents" replace />} />
-92
View File
@@ -1,92 +0,0 @@
import React from 'react';
import { DownloadIcon } from '../ui/icons';
import { formatFileSize } from '../utils/format';
const PreviewWorkspace = ({
document,
previewEntry,
resolveApiPath,
onClose,
onRegenerateThumbnails,
}) => {
if (!document) {
return null;
}
const title = document.title || document.original_name || 'Document';
const mime = previewEntry?.contentType || document.content_type || 'application/pdf';
const downloadHref = document.current_version?.download_path
? resolveApiPath(document.current_version.download_path)
: null;
const sizeBytes = Number(document.current_version?.size_bytes) || 0;
const sizeLabel = sizeBytes > 0 ? formatFileSize(sizeBytes) : null;
const metadata =
document.metadata && Object.keys(document.metadata).length > 0 ? document.metadata : null;
return (
<section className="preview-workspace">
<header className="preview-workspace__header">
<div className="preview-workspace__meta">
<button
type="button"
className="secondary"
onClick={() => onClose(document.folder_id ?? 'root')}
>
Back
</button>
<div>
<h2>{title}</h2>
<span className="meta">
{document.content_type || mime}
{sizeLabel ? ` · ${sizeLabel}` : ''}
</span>
</div>
</div>
<div className="preview-workspace__actions">
<a
className="button-link with-icon"
href={downloadHref || '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!downloadHref}
onClick={(event) => {
if (!downloadHref) {
event.preventDefault();
}
}}
>
<DownloadIcon className="icon-inline" />
<span>Download</span>
</a>
<button
type="button"
className="secondary"
onClick={() => onRegenerateThumbnails(document.id)}
>
Re-run analysis
</button>
</div>
</header>
<div className="preview-workspace__body">
{!previewEntry?.url ? (
<div className="preview-workspace__message">Loading preview</div>
) : (
<iframe
src={previewEntry.url}
title={`Preview of ${title}`}
className="preview-workspace__object"
/>
)}
</div>
{metadata && (
<section className="preview-workspace__metadata">
<h3>Metadata</h3>
<pre>{JSON.stringify(metadata, null, 2)}</pre>
</section>
)}
</section>
);
};
export default PreviewWorkspace;
@@ -1,72 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAppShell } from '../appShellContext';
import PreviewWorkspace from '../preview/PreviewWorkspace';
const DocumentViewerRoute = () => {
const {
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
notifyApiError,
resolveApiPath,
} = useAppShell();
const { documentId } = useParams();
const navigate = useNavigate();
useEffect(() => {
if (!documentId) {
navigate('/documents', { replace: true });
return;
}
let cancelled = false;
const hydrate = async () => {
try {
await ensurePreviewData(documentId);
} catch (error) {
if (cancelled) {
return;
}
notifyApiError(error, 'Failed to open document preview.');
navigate('/documents', { replace: true });
}
};
hydrate();
return () => {
cancelled = true;
};
}, [documentId, ensurePreviewData, notifyApiError, navigate]);
const isReady =
documentId && previewWorkspaceDocument && previewWorkspaceDocument.id === documentId;
if (!isReady) {
return (
<main className="preview-main">
<div className="preview-workspace__message">Loading preview</div>
</main>
);
}
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
resolveApiPath={resolveApiPath}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
};
export default DocumentViewerRoute;
+3 -2
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons'; import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
const FolderNode = ({ const FolderNode = ({
@@ -67,7 +68,7 @@ const FolderNode = ({
</span> </span>
)} )}
<span className="name"> <span className="name">
<FolderIcon className="folder-icon-image" size={16} /> <img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
{node.name} {node.name}
</span> </span>
{node.id !== 'root' && ( {node.id !== 'root' && (
+63 -56
View File
@@ -10,7 +10,6 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-column: 2 / -1;
min-height: 0; min-height: 0;
position: relative; position: relative;
background-color: var(--surface-subtle); background-color: var(--surface-subtle);
@@ -159,7 +158,7 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
padding: 0.28rem 0.7rem; padding: 0.28rem 0.7rem;
border-radius: 1rem; border-radius: 1px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface); background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg); color: var(--fg);
font-size: 1rem; font-size: 1rem;
@@ -189,63 +188,72 @@
user-select: none; user-select: none;
} }
.skeuo-card__nav {
position: absolute;
bottom: calc(3em * 0.707 * var(--nav-scale));
left: 50%;
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
transform-origin: center;
display: flex;
gap: 2rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.skeuo-item__card:hover .skeuo-card__nav {
opacity: 1;
pointer-events: auto;
}
.skeuo-card__nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3.3em;
height: 3.3em;
padding: 0.36em;
border-radius: 3.3em;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
}
.skeuo-card__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
}
.skeuo-card__nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.skeuo-card__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px;
}
.skeuo-card__nav-button svg {
width: 100%;
height: 100%;
}
.skeuo-item__tags .skeuo-tag.is-tear-pending { .skeuo-item__tags .skeuo-tag.is-tear-pending {
opacity: 0.4; 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: 100000;
}
.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,
body.skeuo-cursor-remove * { body.skeuo-cursor-remove * {
cursor: not-allowed !important; cursor: not-allowed !important;
@@ -263,7 +271,6 @@ body.skeuo-cursor-remove * {
justify-content: center; justify-content: center;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24); box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
overflow: hidden; overflow: hidden;
--nav-scale: 1;
} }
.skeuo-item__card--empty { .skeuo-item__card--empty {
+215 -151
View File
@@ -6,10 +6,8 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager'; import { resolveDocumentAssetUrl } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator'; import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css'; import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220; const ITEM_WIDTH = 220;
@@ -33,6 +31,7 @@ const ZOOM_MIN_SCALE = 1.05;
const ZOOM_MAX_SCALE = 5; const ZOOM_MAX_SCALE = 5;
const TAG_REMOVE_DISTANCE = 160; const TAG_REMOVE_DISTANCE = 160;
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
const DEBUG_DRAG = false; const DEBUG_DRAG = false;
const DEBUG_FOCUS = true; const DEBUG_FOCUS = true;
const DEBUG_DROP = true; const DEBUG_DROP = true;
@@ -45,107 +44,6 @@ const resolveTagKey = (tag) => {
return key != null ? String(key) : null; return key != null ? String(key) : null;
}; };
const SkeuoPreviewCard = ({
doc,
title,
ensureAssetUrl,
getDocumentAsset,
navScale = 1,
prefetch = 3,
}) => {
const navigator = useAssetNavigator({
document: doc,
assetType: 'preview',
ensureAssetUrl,
getAsset: getDocumentAsset,
prefetch,
});
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
const hasPreview = Boolean(currentUrl);
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
return (
<div className={cardClasses.join(' ')}>
{hasPreview ? (
<img src={currentUrl} alt={title} />
) : (
<div className="skeuo-item__empty">
<div className="skeuo-item__placeholder">DOC</div>
<div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
{showNav ? (
<div className="skeuo-card__nav" style={navStyle}>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goPrev();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoPrev}
aria-label="Previous preview"
>
<ArrowLeftIcon />
</button>
<button
type="button"
className="skeuo-card__nav-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
navigator.goNext();
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onPointerUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onMouseUp={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={!canGoNext}
aria-label="Next preview"
>
<ArrowRightIcon />
</button>
</div>
) : null}
</div>
);
};
const generateInitialLayout = ( const generateInitialLayout = (
entries, entries,
{ {
@@ -313,15 +211,6 @@ const useDocumentDrag = ({
if (!state || state.pointerId !== pointerId) { if (!state || state.pointerId !== pointerId) {
return; return;
} }
const capturedTarget = state.capturedTarget;
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
try {
capturedTarget.releasePointerCapture(pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = null; dragStateRef.current = null;
setDraggingId((current) => (current === state.docId ? null : current)); setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot(); syncLayoutSnapshot();
@@ -358,15 +247,6 @@ const useDocumentDrag = ({
} }
bringToFront(docId); bringToFront(docId);
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
try {
capturedTarget.setPointerCapture(event.pointerId);
} catch (
// eslint-disable-next-line no-empty
error
) {}
}
dragStateRef.current = { dragStateRef.current = {
docId, docId,
pointerId: event.pointerId, pointerId: event.pointerId,
@@ -380,7 +260,6 @@ const useDocumentDrag = ({
width: docWidth, width: docWidth,
height: docHeight, height: docHeight,
scale: baseScale, scale: baseScale,
capturedTarget,
}; };
setDraggingId(docId); setDraggingId(docId);
}, },
@@ -553,17 +432,20 @@ const SkeuomorphicWorkspace = ({
onRefresh, onRefresh,
onDocumentOpen, onDocumentOpen,
resolveThumbnailUrl, resolveThumbnailUrl,
availableTags = [],
onCreateTag = null,
onAssignTagToDocument = null, onAssignTagToDocument = null,
onRemoveTagFromDocument = null, onRemoveTagFromDocument = null,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
activeTagIds = [], prepareTagPayload = null,
}) => { }) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
const showingSearchResults = searchResults !== null; const showingSearchResults = searchResults !== null;
const containerRef = useRef(null); const containerRef = useRef(null);
const tagShelfRef = useRef(null);
const layoutRef = useRef(new Map()); const layoutRef = useRef(new Map());
const itemRefs = useRef(new Map()); const itemRefs = useRef(new Map());
const zCounterRef = useRef(10); const zCounterRef = useRef(10);
@@ -571,27 +453,17 @@ const SkeuomorphicWorkspace = ({
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map()); const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [tagShelfWidth, setTagShelfWidth] = useState(0);
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState(null);
const [zoomedId, setZoomedId] = useState(null); const [zoomedId, setZoomedId] = useState(null);
const [tagDropTargetId, setTagDropTargetId] = useState(null); const [tagDropTargetId, setTagDropTargetId] = useState(null);
const [pendingTagDocId, setPendingTagDocId] = useState(null); const [pendingTagDocId, setPendingTagDocId] = useState(null);
const [pendingRemovalTag, setPendingRemovalTag] = useState(null); const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
const draggingTagRef = useRef(null); const draggingTagRef = useRef(null);
const pendingDocTagDragRef = useRef(null); const pendingDocTagDragRef = useRef(null);
const docSizeMapRef = useRef(new Map()); const docSizeMapRef = useRef(new Map());
const removalCursorActiveRef = useRef(false); const removalCursorActiveRef = useRef(false);
const activeTagSet = useMemo(() => {
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
return new Set();
}
const set = new Set();
activeTagIds.forEach((id) => {
if (id != null) {
set.add(String(id));
}
});
return set;
}, [activeTagIds]);
const resolvePreviewAsset = useCallback( const resolvePreviewAsset = useCallback(
(doc) => { (doc) => {
@@ -605,10 +477,8 @@ const SkeuomorphicWorkspace = ({
(doc) => { (doc) => {
if (!doc) return null; if (!doc) return null;
const asset = resolvePreviewAsset(doc); const asset = resolvePreviewAsset(doc);
const view = createAssetView(asset); const width = asset?.metadata?.width;
const metadata = view.getPrimaryMetadata() || {}; const height = asset?.metadata?.height;
const width = metadata?.width;
const height = metadata?.height;
if (typeof width === 'number' && typeof height === 'number') { if (typeof width === 'number' && typeof height === 'number') {
return { width, height }; return { width, height };
} }
@@ -617,6 +487,17 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewAsset], [resolvePreviewAsset],
); );
const resolvePreviewUrl = useCallback(
(doc) => {
if (!doc) return null;
return resolveDocumentAssetUrl(doc, 'preview', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
},
[ensureAssetUrl, getDocumentAsset],
);
useEffect(() => { useEffect(() => {
if (!ensureAssetUrl) { if (!ensureAssetUrl) {
return; return;
@@ -710,11 +591,72 @@ const SkeuomorphicWorkspace = ({
); );
}, []); }, []);
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(() => { const handleTagDragEnd = useCallback(() => {
updateRemovalCursor(false); updateRemovalCursor(false);
setTagDropTargetId(null); setTagDropTargetId(null);
}, [updateRemovalCursor]); }, [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 ensureDocumentSize = useCallback((doc) => {
const key = resolveSizeKey(doc); const key = resolveSizeKey(doc);
const cache = docSizeMapRef.current.get(key); const cache = docSizeMapRef.current.get(key);
@@ -779,6 +721,17 @@ const SkeuomorphicWorkspace = ({
docSizeMapRef.current = new Map(); docSizeMapRef.current = new Map();
}, [items]); }, [items]);
useEffect(() => {
if (!activeShelfTagId) {
return;
}
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId);
if (!stillExists) {
setActiveShelfTagId(null);
}
}, [activeShelfTagId, availableTags]);
const resolveZoomMetrics = useCallback( const resolveZoomMetrics = useCallback(
(doc, cardWidth, cardHeight) => { (doc, cardWidth, cardHeight) => {
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH; const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
@@ -868,6 +821,30 @@ const SkeuomorphicWorkspace = ({
[resolvePreviewDimensions], [resolvePreviewDimensions],
); );
useLayoutEffect(() => {
const shelfNode = tagShelfRef.current;
if (!shelfNode || !availableTags.length) {
setTagShelfWidth(0);
return () => {};
}
const measure = () => {
const rect = shelfNode.getBoundingClientRect();
setTagShelfWidth(Math.ceil(rect.width));
};
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver(() => measure());
observer.observe(shelfNode);
return () => observer.disconnect();
}, [availableTags.length]);
const syncLayoutSnapshot = useCallback(() => { const syncLayoutSnapshot = useCallback(() => {
setLayoutSnapshot(new Map(layoutRef.current)); setLayoutSnapshot(new Map(layoutRef.current));
}, []); }, []);
@@ -973,7 +950,7 @@ const SkeuomorphicWorkspace = ({
startZ: maxZ, startZ: maxZ,
rotationRange: ROTATION_RANGE, rotationRange: ROTATION_RANGE,
minSpacing: 48, minSpacing: 48,
shelfWidth: 0, shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
}, },
); );
generatedLayout.forEach((entry, docId) => { generatedLayout.forEach((entry, docId) => {
@@ -994,6 +971,7 @@ const SkeuomorphicWorkspace = ({
canvasSize.height, canvasSize.height,
ensureDocumentSize, ensureDocumentSize,
syncLayoutSnapshot, syncLayoutSnapshot,
tagShelfWidth,
]); ]);
useEffect(() => { useEffect(() => {
@@ -1172,7 +1150,7 @@ const SkeuomorphicWorkspace = ({
setPendingTagDocId(doc.id); setPendingTagDocId(doc.id);
try { try {
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload }); await onAssignTagToDocument({ documentId: doc.id, tagId });
markActiveTagDropHandled(tagId, sourceDocId); markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) { if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id); console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
@@ -1526,6 +1504,7 @@ const SkeuomorphicWorkspace = ({
<div <div
className="skeuo-canvas" className="skeuo-canvas"
ref={containerRef} ref={containerRef}
data-active-tag={activeShelfTagId || undefined}
onDragOver={handleCanvasDragOver} onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave} onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop} onDrop={handleCanvasDrop}
@@ -1583,6 +1562,9 @@ const SkeuomorphicWorkspace = ({
const totalScale = zoomScale > 0 ? zoomScale : 1; const totalScale = zoomScale > 0 ? zoomScale : 1;
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1; const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
const tagsStyle = { '--tag-scale': inverseTagScale }; const tagsStyle = { '--tag-scale': inverseTagScale };
const previewUrl = resolvePreviewUrl(doc);
const imageUrl = previewUrl;
const hasPreview = Boolean(imageUrl);
const title = doc.title || doc.original_name || 'Document'; const title = doc.title || doc.original_name || 'Document';
const dragging = draggingId === doc.id; const dragging = draggingId === doc.id;
const tags = Array.isArray(doc.tags) ? doc.tags : []; const tags = Array.isArray(doc.tags) ? doc.tags : [];
@@ -1590,7 +1572,7 @@ const SkeuomorphicWorkspace = ({
.map((tag) => resolveTagKey(tag)) .map((tag) => resolveTagKey(tag))
.filter(Boolean); .filter(Boolean);
const matchesFilter = const matchesFilter =
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key)); !activeShelfTagId || docTagKeys.includes(activeShelfTagId);
const dropActive = tagDropTargetId === doc.id; const dropActive = tagDropTargetId === doc.id;
const dropPending = pendingTagDocId === doc.id; const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item']; const itemClasses = ['skeuo-item'];
@@ -1599,6 +1581,8 @@ const SkeuomorphicWorkspace = ({
if (dropActive) itemClasses.push('is-tag-target'); if (dropActive) itemClasses.push('is-tag-target');
if (dropPending) itemClasses.push('is-tag-pending'); if (dropPending) itemClasses.push('is-tag-pending');
if (!matchesFilter) itemClasses.push('is-filtered-out'); if (!matchesFilter) itemClasses.push('is-filtered-out');
const cardClasses = ['skeuo-item__card'];
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
const docTagTokens = docTagKeys.join(' '); const docTagTokens = docTagKeys.join(' ');
return ( return (
<div <div
@@ -1634,13 +1618,18 @@ const SkeuomorphicWorkspace = ({
}} }}
> >
<div className="skeuo-item__body" style={bodyStyle}> <div className="skeuo-item__body" style={bodyStyle}>
<SkeuoPreviewCard <div className={cardClasses.join(' ')}>
doc={doc} {hasPreview ? (
title={title} <img src={imageUrl} alt={title} />
ensureAssetUrl={ensureAssetUrl} ) : (
getDocumentAsset={getDocumentAsset} <div className="skeuo-item__empty">
navScale={inverseTagScale} <div className="skeuo-item__placeholder">DOC</div>
/> <div className="skeuo-item__title" title={title}>
{title}
</div>
</div>
)}
</div>
{tags.length > 0 && ( {tags.length > 0 && (
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}> <div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
{tags.map((tag) => { {tags.map((tag) => {
@@ -1685,6 +1674,81 @@ const SkeuomorphicWorkspace = ({
); );
}) })
)} )}
{(availableTags.length > 0 || onCreateTag) && (
<div
className="skeuo-tag-shelf"
aria-label="Available tags"
ref={tagShelfRef}
>
{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 (
<div
key={tag.id || tag.label}
className={shelfTagClasses.join(' ')}
style={tagStyle}
title={tag.label}
draggable
data-tag-id={tagKey || undefined}
onDragStart={(event) => handleShelfTagDragStart(event, tag)}
onDragEnd={handleShelfTagDragEndWithReset}
role="button"
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));
}
}}
>
<span>{tag.label}</span>
</div>
);
})}
{typeof onCreateTag === 'function' && (
<button
type="button"
className="skeuo-tag-add"
aria-label="Add tag"
onClick={async () => {
const labelInput = window.prompt('New tag name?');
const label = labelInput ? labelInput.trim() : '';
if (!label) {
return;
}
try {
const payload =
typeof prepareTagPayload === 'function'
? prepareTagPayload({ label })
: { label, color: generateRandomTagColor() };
await onCreateTag(payload);
setActiveShelfTagId(null);
} catch (error) {
console.error('Failed to create tag', error);
}
}}
>
+
</button>
)}
</div>
)}
</div> </div>
</div> </div>
); );
+29 -111
View File
@@ -36,8 +36,6 @@
--sidebar-hover-bg: rgba(63, 106, 216, 0.08); --sidebar-hover-bg: rgba(63, 106, 216, 0.08);
--sidebar-active-bg: rgba(63, 106, 216, 0.16); --sidebar-active-bg: rgba(63, 106, 216, 0.16);
--selection-ring: #9bb6ff; --selection-ring: #9bb6ff;
--documents-grid-title-size: 12px;
--documents-grid-icon-size: 128px;
/* Semantic colors */ /* Semantic colors */
--success: #2e7d6b; --success: #2e7d6b;
@@ -393,7 +391,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.documents-main { .documents-main {
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: 20rem minmax(0, 1fr) 30rem; grid-template-columns: 20rem minmax(0, 1fr) 20rem;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -751,6 +749,7 @@ button.icon-button.ghost:hover:not([disabled]) {
} }
.column + .column { .column + .column {
border-left: 1px solid var(--border);
padding-left: 1.25rem; padding-left: 1.25rem;
} }
@@ -859,6 +858,12 @@ button.icon-button.ghost:hover:not([disabled]) {
padding-right: 3rem; padding-right: 3rem;
} }
.folder-row span.name .folder-icon-image {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.folder-row:hover { .folder-row:hover {
background: var(--sidebar-hover-bg); background: var(--sidebar-hover-bg);
color: var(--fg); color: var(--fg);
@@ -940,7 +945,6 @@ button.icon-button.ghost:hover:not([disabled]) {
overflow-y: auto; overflow-y: auto;
padding: 1.25rem; padding: 1.25rem;
color: var(--sidebar-fg); color: var(--sidebar-fg);
border-right: 1px solid var(--border);
} }
.sidebar-section:first-of-type, .sidebar-section:first-of-type,
@@ -1178,49 +1182,34 @@ button.icon-button.ghost:hover:not([disabled]) {
text-align: center; text-align: center;
} }
.documents-panel--view-grid .document-thumbnail-wrapper { .document-thumbnail-wrapper {
width: var(--documents-grid-icon-size); width: 48px;
height: var(--documents-grid-icon-size); height: 48px;
padding: 8px;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.document-thumbnail-inner {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.document-thumbnail-inner--multipage::after { .documents-panel--view-grid .document-thumbnail-wrapper {
content: ''; width: 128px;
position: absolute; height: 128px;
top: 0; padding: 8px;
right: 0; display: flex;
width: 24px; align-items: center;
height: 24px; justify-content: center;
background-image: url('./assets/papercorner.svg'); border-radius: 10px;
background-repeat: no-repeat; overflow: hidden;
background-size: contain;
pointer-events: none;
}
.documents-panel--view-grid .document-thumbnail-inner--multipage::after {
width: 28px;
height: 28px;
} }
.document-thumbnail { .document-thumbnail {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
box-shadow: 0 1px 6px var(--shadow-medium); object-fit: contain;
} }
.documents-panel--view-grid .document-thumbnail { .documents-panel--view-grid .document-thumbnail {
box-shadow: 0 1px 12px var(--shadow-medium); box-shadow: 0 1px 12px var(--shadow-medium);
background: transparent;
} }
.thumb-placeholder { .thumb-placeholder {
@@ -1318,7 +1307,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.documents-grid { .documents-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--documents-grid-icon-size), 1fr)); grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 0.3rem 1.3rem; gap: 0.3rem 1.3rem;
padding: 0; padding: 0;
} }
@@ -1360,7 +1349,6 @@ button.icon-button.ghost:hover:not([disabled]) {
border-radius: 1rem; border-radius: 1rem;
max-width: 100%; max-width: 100%;
word-break: break-word; word-break: break-word;
font-size: var(--documents-grid-title-size);
} }
.document-card.selected .document-card__title { .document-card.selected .document-card__title {
@@ -1389,8 +1377,8 @@ button.icon-button.ghost:hover:not([disabled]) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: var(--documents-grid-icon-size); width: 128px;
height: var(--documents-grid-icon-size); height: 128px;
} }
.folder-card__icon-svg { .folder-card__icon-svg {
@@ -1411,11 +1399,11 @@ button.icon-button.ghost:hover:not([disabled]) {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: break-word; white-space: break-word;
font-size: var(--documents-grid-title-size);
} }
.doc-name__title { .doc-name__title {
font-weight: 600;
max-width: 100%; max-width: 100%;
word-break: break-word; word-break: break-word;
} }
@@ -1509,7 +1497,6 @@ button.icon-button.ghost:hover:not([disabled]) {
position: relative; position: relative;
padding: 1.25rem; padding: 1.25rem;
overflow-y: auto; overflow-y: auto;
border-left: 1px solid var(--border);
} }
.detail-panel .column-body { .detail-panel .column-body {
@@ -1590,7 +1577,6 @@ button.icon-button.ghost:hover:not([disabled]) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: visible; overflow: visible;
position: relative;
} }
.thumbnail-preview { .thumbnail-preview {
@@ -1673,7 +1659,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.preview-stack--stacked { .preview-stack--stacked {
width: 100%; width: 100%;
min-height: 420px; min-height: 320px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1729,75 +1715,11 @@ button.icon-button.ghost:hover:not([disabled]) {
box-shadow: box-shadow:
inset 0 0 0 999px var(--accent-elevated), inset 0 0 0 999px var(--accent-elevated),
0 6px 18px var(--accent-elevated-strong); 0 6px 18px var(--accent-elevated-strong);
filter: saturate(118%) brightness(1.05);
} }
.preview-pane--stack { .preview-pane--stack {
min-height: 460px; min-height: 260px;
position: relative;
--preview-nav-scale: 1;
}
.preview-pane__nav-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2em;
height: 2.2em;
padding: 0.45em;
border-radius: 999px;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
pointer-events: auto;
}
.preview-pane__nav-button:hover:not([disabled]) {
background: rgba(0, 0, 0, 0.75);
}
.preview-pane__nav-button:disabled {
opacity: 0.4;
cursor: default;
}
.preview-pane__nav-button:focus-visible {
outline: 2px solid var(--accent, #2684ff);
outline-offset: 2px;
}
.preview-pane__nav {
margin-top: 0.5rem;
display: flex;
justify-content: center;
gap: 0.5rem;
}
.preview-pane__nav--overlay {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%) scale(var(--preview-nav-scale, 1));
margin-top: 0;
pointer-events: none;
z-index: 20;
opacity: 0;
transition: opacity 0.2s ease;
}
.preview-pane__nav--overlay .preview-pane__nav-button {
pointer-events: auto;
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
}
.preview-pane--stack:hover .preview-pane__nav--overlay,
.skeuo-item__card:hover .preview-pane__nav--overlay {
opacity: 1;
}
.skeuo-card__nav {
bottom: 0.5rem;
} }
.bulk-tags { .bulk-tags {
@@ -2088,7 +2010,3 @@ form.inline {
width: 0.9rem; width: 0.9rem;
height: 0.9rem; height: 0.9rem;
} }
.preview-pane__nav-button svg {
width: 100%;
height: 100%;
}
+9 -36
View File
@@ -1,16 +1,14 @@
import { import {
IconChevronRight as TablerChevronRight, IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload, IconDownload as TablerDownload,
IconFolderFilled,
IconPencil, IconPencil,
IconTagFilled, IconTagFilled,
IconUserFilled, IconUserFilled,
IconTrash, IconTrash,
IconLayoutList, IconLayoutList,
IconLayoutGrid, IconLayoutGrid,
IconArrowLeft,
IconArrowRight,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base); const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
@@ -41,21 +39,14 @@ export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
/> />
); );
export const FolderIcon = ({ className, size = 16, title, ...rest }) => { export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
const dimensionProps = typeof size === 'number' ? { width: size, height: size } : {}; <IconFolderFilled
className={composeClassName('icon icon--fill', className)}
return ( size={size}
<FolderSvg stroke={stroke}
className={composeClassName('folder-icon', className)} {...rest}
{...dimensionProps} />
role={title ? 'img' : 'presentation'} );
aria-hidden={title ? undefined : true}
focusable="false"
title={title}
{...rest}
/>
);
};
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => ( export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconTagFilled <IconTagFilled
@@ -102,24 +93,6 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
/> />
); );
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowLeft
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconArrowRight
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export default { export default {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
-10
View File
@@ -29,16 +29,6 @@ module.exports = {
test: /\.css$/i, test: /\.css$/i,
use: ['style-loader', 'css-loader'], use: ['style-loader', 'css-loader'],
}, },
{
test: /\.svg$/i,
issuer: /\.[jt]sx?$/,
use: ['@svgr/webpack'],
},
{
test: /\.svg$/i,
type: 'asset/resource',
issuer: { not: [/\.[jt]sx?$/] },
},
], ],
}, },
plugins: [ plugins: [
-11
View File
@@ -1,11 +0,0 @@
# tmux session for Papercrate dev stack
new-session -d -s papercrate -n docker -c . 'docker compose up'
new-window -t papercrate:1 -n frontend -c ./frontend 'npm run dev'
new-window -t papercrate:2 -n backend -c ./backend 'cargo run --bin backend'
new-window -t papercrate:3 -n worker -c ./backend 'cargo run --bin worker'
select-window -t papercrate:0
attach-session -t papercrate