14 Commits
Author SHA1 Message Date
nils 5619bb27a1 frontend: styling
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 24s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 50s
2025-10-20 13:51:43 +02:00
nils dfc99c7d15 frontend: rounded tags 2025-10-20 11:32:46 +02:00
nils fb82f505b6 frontend: tweak documents table
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 23s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 51s
2025-10-20 10:45:01 +02:00
nils 5970340a17 frontend: fix regression when pointer leaves document while dragging
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 20s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 46s
2025-10-19 23:26:27 +02:00
nils 9a77e76ff4 frontend: make skeuomorphic workspace compatible with sidebar
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 14m32s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 14m31s
2025-10-19 23:05:38 +02:00
nils 3591415d46 frontend: refactor preview 2025-10-19 21:23:35 +02:00
nils ab06205744 backend: split document assets into assets and objects 2025-10-19 19:46:16 +02:00
nils 0689e680fa style fix 2025-10-18 09:22:38 +02:00
nils 297d5aca1f frontend: papercorner for multi-page documents 2025-10-18 01:33:12 +02:00
nils 0a023c1556 frontend: show page count in DetailPanel 2025-10-17 23:19:09 +02:00
nils aa600af7b2 frontend: fix drop shadow for thumbnails 2025-10-17 23:18:18 +02:00
nils fc4aae8c0c frontend: fix folder.svg usage 2025-10-17 23:14:44 +02:00
nils 24c0b5fa52 add tmux workflow for local dev
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 19s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 20s
2025-10-17 21:56:54 +02:00
nils 2af2af3460 Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 21s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 23s
2025-10-17 20:19:47 +02:00
28 changed files with 1661 additions and 649 deletions
+10
View File
@@ -1,5 +1,15 @@
# 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
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
@@ -0,0 +1,34 @@
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;
@@ -0,0 +1,54 @@
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;
+13 -5
View File
@@ -2,13 +2,14 @@ use std::env;
use anyhow::{Context, Result};
use diesel::prelude::*;
use uuid::Uuid;
use backend::{
config::AppConfig,
db,
models::DocumentAsset,
models::{DocumentAsset, DocumentAssetObject},
s3,
schema::document_assets,
schema::{document_asset_objects, document_assets},
storage::{ObjectStorage, S3Storage},
};
@@ -57,11 +58,18 @@ async fn delete_all_assets() -> Result<()> {
println!("Deleting {} assets…", assets.len());
for asset in &assets {
if let Err(err) = storage.delete_object(&asset.s3_key).await {
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
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!(
"Failed to delete object {} from storage: {err}",
asset.s3_key
object.s3_key
);
}
}
+23 -2
View File
@@ -109,10 +109,10 @@ pub struct DocumentAsset {
pub id: Uuid,
pub document_version_id: Uuid,
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub metadata: serde_json::Value,
pub created_at: NaiveDateTime,
pub cardinality: Option<i32>,
}
#[derive(Debug, Insertable)]
@@ -121,9 +121,30 @@ pub struct NewDocumentAsset {
pub id: Uuid,
pub document_version_id: Uuid,
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
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)]
+63 -14
View File
@@ -22,12 +22,13 @@ use crate::auth::AuthenticatedUser;
use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
use crate::models::{
Correspondent, Document, DocumentAsset, DocumentCorrespondent, DocumentVersion, NewDocument,
NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, Tag,
Correspondent, Document, DocumentAsset, DocumentAssetObject, DocumentCorrespondent,
DocumentVersion, NewDocument, NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion,
Tag,
};
use crate::schema::{
correspondents, document_assets, document_correspondents, document_tags, document_versions,
documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
correspondents, document_asset_objects, document_assets, document_correspondents,
document_tags, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
};
use crate::state::AppState;
@@ -856,7 +857,14 @@ pub async fn get_document_asset(
return Err(AppError::not_found());
}
let asset: DocumentAsset = document_assets::table.find(asset_id).first(&mut conn)?;
let (asset, object): (DocumentAsset, DocumentAssetObject) = document_assets::table
.inner_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::id.eq(asset_id))
.first(&mut conn)?;
let version: DocumentVersion = document_versions::table
.find(asset.document_version_id)
.first(&mut conn)?;
@@ -865,7 +873,8 @@ pub async fn get_document_asset(
return Err(AppError::not_found());
}
let s3_key = asset.s3_key.clone();
let s3_key = object.s3_key.clone();
let object_metadata = object.metadata.clone();
drop(conn);
let presigned_url = state
@@ -874,7 +883,11 @@ pub async fn get_document_asset(
.await
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
Ok(Json(to_asset_response(asset, Some(presigned_url))))
Ok(Json(to_asset_response(
asset,
Some(object_metadata),
Some(presigned_url),
)))
}
pub async fn download_document(
@@ -1784,18 +1797,27 @@ pub(crate) async fn load_primary_assets(
version_map.insert(version.id, version);
}
let assets: Vec<DocumentAsset> = document_assets::table
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = 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))
.order((
document_assets::document_version_id.asc(),
document_assets::created_at.asc(),
))
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
for asset in assets {
for (asset, object) in assets {
let version_id = asset.document_version_id;
let response = to_asset_response(asset, None);
let response = to_asset_response(asset, object.map(|o| o.metadata), None);
assets_by_version
.entry(version_id)
.or_default()
@@ -1878,8 +1900,26 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
}
}
fn to_asset_response(asset: DocumentAsset, url: Option<String>) -> DocumentAssetResponse {
let metadata = asset.metadata.clone();
fn merge_metadata(base: &Value, overlay: Option<&Value>) -> Value {
match (base, overlay) {
(Value::Object(base_obj), Some(Value::Object(overlay_obj))) => {
let mut merged = base_obj.clone();
for (key, value) in overlay_obj {
merged.insert(key.clone(), value.clone());
}
Value::Object(merged)
}
(_, Some(value)) => value.clone(),
(value, None) => value.clone(),
}
}
fn to_asset_response(
asset: DocumentAsset,
object_metadata: Option<Value>,
url: Option<String>,
) -> DocumentAssetResponse {
let metadata = merge_metadata(&asset.metadata, object_metadata.as_ref());
DocumentAssetResponse {
id: asset.id,
asset_type: asset.asset_type,
@@ -1931,15 +1971,24 @@ async fn load_asset_responses(
version_id: Uuid,
) -> AppResult<Vec<DocumentAssetResponse>> {
let mut conn = state.db()?;
let assets: Vec<DocumentAsset> = document_assets::table
let assets: Vec<(DocumentAsset, Option<DocumentAssetObject>)> = 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))
.order(document_assets::created_at.asc())
.select((
document_assets::all_columns,
document_asset_objects::all_columns.nullable(),
))
.load(&mut conn)?;
drop(conn);
Ok(assets
.into_iter()
.map(|asset| to_asset_response(asset, None))
.map(|(asset, object)| to_asset_response(asset, object.map(|o| o.metadata), None))
.collect())
}
+13 -1
View File
@@ -11,15 +11,25 @@ diesel::table! {
}
}
diesel::table! {
document_asset_objects (id) {
id -> Uuid,
asset_id -> Uuid,
ordinal -> Int4,
s3_key -> Text,
metadata -> Jsonb,
}
}
diesel::table! {
document_assets (id) {
id -> Uuid,
document_version_id -> Uuid,
asset_type -> Text,
s3_key -> Text,
mime_type -> Text,
metadata -> Jsonb,
created_at -> Timestamptz,
cardinality -> Nullable<Int4>,
}
}
@@ -143,6 +153,7 @@ diesel::table! {
}
}
diesel::joinable!(document_asset_objects -> document_assets (asset_id));
diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_id));
@@ -155,6 +166,7 @@ diesel::joinable!(refresh_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
correspondents,
document_asset_objects,
document_assets,
document_correspondents,
document_tags,
+13 -8
View File
@@ -12,8 +12,8 @@ use uuid::Uuid;
use crate::{
jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentAsset, DocumentVersion},
schema::{document_assets, document_versions, documents},
models::{Document, DocumentVersion},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState,
};
@@ -87,15 +87,15 @@ impl JobHandler for IndexDocumentTextJob {
}
};
if context.text_asset.is_none() {
if context.text_s3_key.is_none() {
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
return JobExecution::Failed {
error: "missing OCR text asset".into(),
};
}
let asset = context.text_asset.unwrap();
let text = match state.storage.get_object(&asset.s3_key).await {
let s3_key = context.text_s3_key.unwrap();
let text = match state.storage.get_object(&s3_key).await {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => {
@@ -169,7 +169,7 @@ impl JobHandler for IndexDocumentTextJob {
struct IndexContext {
document: Document,
version: DocumentVersion,
text_asset: Option<DocumentAsset>,
text_s3_key: Option<String>,
}
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
@@ -189,9 +189,14 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let text_asset: Option<DocumentAsset> = document_assets::table
let text_s3_key: Option<String> = document_asset_objects::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::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.filter(document_asset_objects::ordinal.eq(1))
.select(document_asset_objects::s3_key)
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
@@ -199,6 +204,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
Ok(IndexContext {
document,
version,
text_asset,
text_s3_key,
})
}
+71 -13
View File
@@ -19,8 +19,11 @@ use uuid::Uuid;
use crate::{
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
schema::{document_assets, document_versions, documents},
models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState,
};
@@ -123,11 +126,15 @@ impl JobHandler for GenerateOcrTextJob {
};
};
let asset_id = context
.existing_asset
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
if context.existing_asset.is_some() {
for object in &context.existing_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 ocr asset object");
}
}
}
let asset_id = Uuid::new_v4();
let s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
@@ -193,6 +200,7 @@ struct OcrContext {
document: Document,
version: DocumentVersion,
existing_asset: Option<DocumentAsset>,
existing_objects: Vec<DocumentAssetObject>,
skip: bool,
}
@@ -218,29 +226,41 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing: Option<DocumentAsset> = document_assets::table
let existing_asset: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.first(&mut conn)
.optional()
.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);
if !is_pdf {
return Ok(OcrContext {
document,
version,
existing_asset: existing,
existing_asset: existing_asset,
existing_objects,
skip: true,
});
}
let skip = existing.is_some() && !payload.force;
let skip = existing_asset.is_some() && !payload.force;
Ok(OcrContext {
document,
version,
existing_asset: existing,
existing_asset,
existing_objects,
skip,
})
}
@@ -371,16 +391,22 @@ fn persist_ocr_metadata(
) -> Result<(), String> {
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 {
id: asset_id,
document_version_id: context.version.id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
s3_key: s3_key.to_string(),
mime_type: "text/plain".to_string(),
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"source": source,
}),
cardinality: Some(1),
};
diesel::insert_into(document_assets::table)
@@ -391,9 +417,41 @@ fn persist_ocr_metadata(
))
.do_update()
.set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::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)
.map_err(|err| format!("{err:?}"))?;
+113 -18
View File
@@ -13,8 +13,11 @@ use uuid::Uuid;
use crate::{
jobs::JOB_GENERATE_THUMBNAILS,
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
schema::{document_assets, document_versions, documents},
models::{
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
NewDocumentAssetObject,
},
schema::{document_asset_objects, document_assets, document_versions, documents},
state::AppState,
};
@@ -142,11 +145,33 @@ impl JobHandler for GenerateThumbnailsJob {
}
}
let thumbnail_asset_id = initial
.existing_thumbnail
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
if initial.existing_preview.is_some() {
for object in &initial.existing_preview_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 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 thumbnail_asset_id = Uuid::new_v4();
let thumbnail_s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
initial.document.id,
@@ -155,11 +180,7 @@ impl JobHandler for GenerateThumbnailsJob {
thumbnail_asset_id
);
let preview_asset_id = initial
.existing_preview
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
let preview_asset_id = Uuid::new_v4();
let preview_s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
initial.document.id,
@@ -250,7 +271,9 @@ struct ThumbnailContext {
document: Document,
version: DocumentVersion,
existing_thumbnail: Option<DocumentAsset>,
existing_thumbnail_objects: Vec<DocumentAssetObject>,
existing_preview: Option<DocumentAsset>,
existing_preview_objects: Vec<DocumentAssetObject>,
skip: bool,
}
@@ -303,11 +326,27 @@ fn load_thumbnail_context(
.map_err(|err| format!("{err:?}"))?;
let mut existing_thumbnail = None;
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
let mut existing_preview = None;
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
for asset in existing_assets {
match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
THUMBNAIL_ASSET_TYPE => {
existing_thumbnail_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_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);
}
_ => {}
}
}
@@ -323,7 +362,9 @@ fn load_thumbnail_context(
document,
version,
existing_thumbnail,
existing_thumbnail_objects,
existing_preview,
existing_preview_objects,
skip,
})
}
@@ -462,18 +503,30 @@ fn persist_assets_metadata(
) -> Result<(), String> {
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 {
let new_asset = NewDocumentAsset {
id: asset.asset_id,
document_version_id: context.version.id,
asset_type: asset.asset_type.to_string(),
s3_key: asset.s3_key.to_string(),
mime_type: "image/png".to_string(),
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"width": asset.generated.width,
"height": asset.generated.height,
}),
cardinality: Some(1),
};
diesel::insert_into(document_assets::table)
@@ -484,9 +537,51 @@ fn persist_assets_metadata(
))
.do_update()
.set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::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.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 mut metadata_map = Map::new();
if let Some(width) = asset.generated.width {
metadata_map.insert("width".to_string(), Value::from(width));
}
if let Some(height) = asset.generated.height {
metadata_map.insert("height".to_string(), Value::from(height));
}
let object_metadata = Value::Object(metadata_map);
let new_object = NewDocumentAssetObject {
id: object_id,
asset_id: asset.asset_id,
ordinal: 1,
s3_key: asset.s3_key.to_string(),
metadata: object_metadata,
};
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)
.map_err(|err| format!("{err:?}"))?;
+1 -1
View File
@@ -44,4 +44,4 @@ npm run build
## Assets
- The folder icon (`public/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
- The folder icon (`src/assets/folder.svg`) is derived from the Adwaita icon theme by the [GNOME Project](http://www.gnome.org/).
+727
View File
@@ -18,6 +18,7 @@
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3",
"@svgr/webpack": "8.1.0",
"babel-loader": "9.2.1",
"css-loader": "7.1.2",
"dotenv": "16.4.5",
@@ -641,6 +642,22 @@
"@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": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
@@ -1300,6 +1317,22 @@
"@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": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
@@ -1499,6 +1532,26 @@
"@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": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
@@ -1696,6 +1749,26 @@
"@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": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -1964,6 +2037,290 @@
"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": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
@@ -1990,6 +2347,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": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2595,6 +2962,13 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -2929,6 +3303,16 @@
"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": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
@@ -2940,6 +3324,19 @@
"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": {
"version": "1.0.30001749",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
@@ -3170,6 +3567,33 @@
"dev": true,
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3238,6 +3662,20 @@
"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": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
@@ -3264,6 +3702,42 @@
"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": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3274,6 +3748,16 @@
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
@@ -3535,6 +4019,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -4447,6 +4941,33 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
@@ -4494,6 +5015,13 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4691,6 +5219,19 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4752,6 +5293,13 @@
"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": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
@@ -4834,6 +5382,13 @@
"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": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -5183,6 +5738,38 @@
"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": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -5238,6 +5825,16 @@
"dev": true,
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6117,6 +6714,17 @@
"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": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -6321,6 +6929,125 @@
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+2 -1
View File
@@ -26,6 +26,7 @@
"style-loader": "4.0.0",
"webpack": "5.95.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.1.0"
"webpack-dev-server": "5.1.0",
"@svgr/webpack": "8.1.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
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;
};
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<svg viewBox="0 0 128 128" 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">
<stop offset="0" stop-color="#62a0ea"/>
<stop offset="0.0576991" stop-color="#afd4ff"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+19
View File
@@ -0,0 +1,19 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

+13
View File
@@ -649,6 +649,14 @@ const DetailPanel = ({
? new Date(singleDoc.issued_at).toLocaleString()
: '—';
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 =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
return (
@@ -728,6 +736,11 @@ const DetailPanel = ({
<div>
<strong>Issued:</strong> {issuedAt}
</div>
{hasPageCount ? (
<div>
<strong>Pages:</strong> {pageCountValue}
</div>
) : null}
<div>
<strong>Original filename:</strong>{' '}
{singleDoc.original_name}
+75 -23
View File
@@ -1,13 +1,45 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { getAssetFromVersion, resolveDocumentAssetUrl } from '../asset_manager';
import { getTagColorStyle } from '../utils/colors';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
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 DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, alt }) => {
const getPageCount = (doc) =>
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 assetWidth = Number(thumbnailAsset?.metadata?.width);
const assetHeight = Number(thumbnailAsset?.metadata?.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(
() =>
resolveDocumentAssetUrl(document, 'thumbnail', {
@@ -17,8 +49,16 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
[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 (
<div className="document-thumbnail-wrapper">
<div className={innerClasses.join(' ')} style={innerStyle}>
{url ? (
<img
src={url}
@@ -31,6 +71,7 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
<div className="thumb-placeholder">DOC</div>
)}
</div>
</div>
);
};
@@ -97,6 +138,7 @@ const DocumentsTable = ({
);
const scrollRef = useRef(null);
const isGridView = viewMode === 'grid';
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
const handleSetViewMode = useCallback(
(nextMode) => {
if (!onViewModeChange) {
@@ -226,6 +268,13 @@ const DocumentsTable = ({
[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 (
<section
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
@@ -295,6 +344,16 @@ const DocumentsTable = ({
</button>
</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
ref={scrollRef}
@@ -315,15 +374,12 @@ const DocumentsTable = ({
}}
aria-activedescendant={isGridView ? undefined : activeDescendantId}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
</div>
) : isGridView ? (
{showDefaultEmptyState ? null : isGridView ? (
<div
className="documents-grid"
role="list"
onClick={handleGridBackgroundClick}
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
>
{!showingSearchResults &&
subfolders.map((folder) => {
@@ -370,10 +426,9 @@ const DocumentsTable = ({
}}
>
<div className="folder-card__icon">
<img
src={FOLDER_ICON_SRC}
alt="Folder"
<FolderIcon
className="folder-card__icon-svg"
size={gridIconSize}
/>
</div>
<div className="folder-card__meta">
@@ -399,6 +454,7 @@ const DocumentsTable = ({
className={cardClasses.join(' ')}
role="listitem"
id={`document-card-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
@@ -416,6 +472,7 @@ const DocumentsTable = ({
ensureAssetUrl={ensureAssetUrl}
getDocumentAsset={getDocumentAsset}
alt={`Thumbnail for ${doc.title || doc.original_name}`}
maxSize={gridIconSize}
/>
<div className="document-card__meta">
<div
@@ -550,10 +607,9 @@ const DocumentsTable = ({
>
<td className="thumb-cell">
<div className="thumb-icon">
<img
src={FOLDER_ICON_SRC}
alt="Folder"
<FolderIcon
className="thumb-icon__image"
size={32}
/>
</div>
</td>
@@ -615,6 +671,7 @@ const DocumentsTable = ({
key={doc.id}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
data-doc-id={doc.id}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onDoubleClick={() => onDocumentOpen(doc.id)}
draggable
@@ -764,20 +821,15 @@ const DocumentsTable = ({
</table>
)}
</div>
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
{showListSearchEmptyState && (
<div className="empty-state">No documents match the current filters.</div>
)}
{showingSearchResults && rows.length > 0 && (
{showSearchHint && (
<div className="search-hint">
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
</div>
)}
</div>
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
<div className="empty-state empty-state--global">
No documents match the current filters.
</div>
)}
</section>
);
};
+108 -262
View File
@@ -1,10 +1,10 @@
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useContext,
useReducer,
} from 'react';
import { createRoot } from 'react-dom/client';
@@ -28,11 +28,11 @@ import DetailPanel from './detail/DetailPanel';
import TagsPanel from './tags/TagsPanel';
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
import { CORRESPONDENT_ROLES } from './constants/correspondents';
import { DownloadIcon } from './ui/icons';
import TagManager from './tag_manager';
import { formatFileSize } from './utils/format';
import Sidebar from './sidebar/Sidebar';
import DocumentsTable from './documents/DocumentsTable';
import { AppShellContext, useAppShell } from './appShellContext';
import DocumentViewerRoute from './routes/DocumentViewerRoute';
const runtimeApiBase =
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
@@ -226,91 +226,6 @@ 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 }) => (
<main className="documents-main">
<Sidebar {...sidebarProps} />
@@ -318,19 +233,16 @@ const DocumentsLayout = ({ sidebarProps, children }) => (
</main>
);
const AppShellContext = React.createContext(null);
const AppLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const appState = useAppState();
const appDispatch = useAppDispatch();
const folderMatch = matchPath('/documents/folder/:folderId', location.pathname);
const nestedDocMatch = matchPath('/documents/folder/:folderId/documents/:documentId', location.pathname);
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
const routeFolderId =
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
const docMatch = matchPath('/documents/:documentId', location.pathname);
const routeFolderId = folderMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null;
const previewDocumentId = routeDocumentId;
const { status: appStatus, token } = appState;
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
@@ -475,8 +387,6 @@ const AppLayout = () => {
folderName: DEFAULT_FOLDER_NAME,
});
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
const [previewDocumentId, setPreviewDocumentId] = useState(null);
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false);
const shellRef = useRef(null);
const assetManagerRef = useRef(null);
@@ -608,8 +518,6 @@ const AppLayout = () => {
setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setActivePreviewId(null);
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
assetManager.reset();
setPreviewEntries(() => new Map());
previewInflightRef.current = new Map();
@@ -754,9 +662,6 @@ const AppLayout = () => {
useEffect(() => {
if (!selectedDocumentIds.length) {
if (activePreviewId !== null) {
setActivePreviewId(null);
}
return;
}
if (!selectedDocumentIds.includes(activePreviewId)) {
@@ -3084,63 +2989,75 @@ const AppLayout = () => {
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
);
const openDocumentPreview = useCallback(
async (documentId, { replace = false, skipNavigate = false } = {}) => {
if (!documentId) return;
setPreviewDocumentId(documentId);
setPreviewDocumentLoading(true);
try {
const ensurePreviewData = useCallback(
async (documentId) => {
if (!documentId) return null;
const findInCache = () => {
const pool = searchResults ?? documents;
const doc = pool.find((item) => item.id === documentId);
return pool.find((item) => item.id === documentId) || null;
};
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) {
throw new Error('Document metadata unavailable.');
}
const currentVersion = doc.current_version || null;
const previewAsset = getAssetFromVersion(currentVersion, 'preview');
const thumbnailAsset = getAssetFromVersion(currentVersion, 'thumbnail');
const refreshAssetIfNeeded = async (asset) => {
if (!asset?.id) {
return;
setDocuments((prev) => {
if (prev.some((item) => item.id === doc.id)) {
return 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
) {}
return [doc, ...prev];
});
}
};
await refreshAssetIfNeeded(previewAsset);
refreshAssetIfNeeded(thumbnailAsset);
const targetFolder = doc?.folder_id || selectedFolder || 'root';
if (targetFolder && targetFolder !== selectedFolder) {
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
doc = findInCache() || doc;
}
await ensurePreviewUrl(documentId, { force: false });
setActivePreviewId(documentId);
if (!skipNavigate && navigate) {
navigate(`/documents/${documentId}`, { replace });
}
} catch (error) {
notifyApiError(error, 'Failed to open document preview.');
setPreviewDocumentId(null);
} finally {
setPreviewDocumentLoading(false);
}
return doc;
},
[
documents,
searchResults,
documents,
api,
assetManager,
setDocuments,
selectedFolder,
loadFolder,
isFilterActive,
ensurePreviewUrl,
ensureAssetUrl,
navigate,
notifyApiError,
setActivePreviewId,
],
);
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(() => {
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
return;
@@ -3269,24 +3186,6 @@ 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(() => {
if (!previewDocumentId) return;
const handleKeyDown = (event) => {
@@ -3298,63 +3197,6 @@ const AppLayout = () => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [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(
async (documentId, nextTitle) => {
const trimmed = nextTitle.trim();
@@ -3458,13 +3300,38 @@ const AppLayout = () => {
);
const handleDocumentTagAttach = useCallback(
async ({ documentId, tagId }) => {
async ({ documentId, tagId, tag: tagData = null }) => {
if (!documentId || !tagId) {
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 {
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');
await refreshCurrentFolder();
return true;
@@ -3474,7 +3341,14 @@ const AppLayout = () => {
return false;
}
},
[api, refreshCurrentFolder, notifyApiError, setStatusMessage],
[
api,
refreshCurrentFolder,
notifyApiError,
setStatusMessage,
updateDocumentCaches,
tagLookupById,
],
);
const handleDocumentTagDrop = useCallback(
@@ -3487,7 +3361,7 @@ const AppLayout = () => {
return;
}
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id });
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag });
if (!attached) {
return;
}
@@ -3952,14 +3826,14 @@ const AppLayout = () => {
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
};
const isDocumentRowTarget = (target) =>
target instanceof Element ? Boolean(target.closest('tr.document')) : false;
const isDocumentDropTarget = (target) =>
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
const handleTagDragOver = (event) => {
if (!isTagTransfer(event)) {
return;
}
if (isDocumentRowTarget(event.target)) {
if (isDocumentDropTarget(event.target)) {
setTagRemovalCursor(false);
return;
}
@@ -3974,7 +3848,7 @@ const AppLayout = () => {
}
const related = event.relatedTarget;
if (related instanceof Element && host.contains(related)) {
if (isDocumentRowTarget(related)) {
if (isDocumentDropTarget(related)) {
setTagRemovalCursor(false);
}
return;
@@ -3987,7 +3861,7 @@ const AppLayout = () => {
return;
}
setTagRemovalCursor(false);
if (isDocumentRowTarget(event.target) || event.defaultPrevented) {
if (isDocumentDropTarget(event.target) || event.defaultPrevented) {
return;
}
event.preventDefault();
@@ -4573,13 +4447,11 @@ const AppLayout = () => {
onRefresh: refreshCurrentFolder,
onDocumentOpen: openDocumentPreview,
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
availableTags: tags,
onCreateTag: handleTagCreate,
onAssignTagToDocument: handleDocumentTagAttach,
onRemoveTagFromDocument: handleTagRemove,
ensureAssetUrl,
getDocumentAsset,
prepareTagPayload: buildTagPayload,
activeTagIds: activeTagFilters,
}),
[
documents,
@@ -4590,13 +4462,11 @@ const AppLayout = () => {
refreshCurrentFolder,
openDocumentPreview,
resolveThumbnailUrlForDoc,
tags,
handleTagCreate,
handleDocumentTagAttach,
handleTagRemove,
ensureAssetUrl,
getDocumentAsset,
buildTagPayload,
activeTagFilters,
],
);
@@ -4635,6 +4505,9 @@ const AppLayout = () => {
showSkeuoWorkspace,
exitSkeuoWorkspace,
skeuoWorkspaceProps,
ensurePreviewData,
notifyApiError,
resolveApiPath,
}),
[
token,
@@ -4670,6 +4543,9 @@ const AppLayout = () => {
showSkeuoWorkspace,
exitSkeuoWorkspace,
skeuoWorkspaceProps,
ensurePreviewData,
notifyApiError,
resolveApiPath,
],
);
@@ -4826,46 +4702,20 @@ 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 {
sidebarProps,
previewActive,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
documentsTableProps,
detailPanelProps,
workspaceMode,
skeuoWorkspaceProps,
} = useAppShell();
if (previewActive && previewWorkspaceDocument) {
return (
<main className="preview-main">
<PreviewWorkspace
document={previewWorkspaceDocument}
previewEntry={previewWorkspaceEntry}
onClose={closeDocumentPreview}
onRegenerateThumbnails={handleThumbnailRegeneration}
/>
</main>
);
}
if (workspaceMode === 'skeuo') {
return (
<main className="skeuo-main">
<DocumentsLayout sidebarProps={sidebarProps}>
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
</main>
</DocumentsLayout>
);
}
@@ -4998,11 +4848,7 @@ const AppRouter = () => (
<Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route
path="/documents/folder/:folderId/documents/:documentId"
element={<DocumentsRoute />}
/>
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentViewerRoute />} />
<Route path="/tags" element={<TagsRoute />} />
<Route path="/correspondents" element={<CorrespondentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} />
+92
View File
@@ -0,0 +1,92 @@
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;
@@ -0,0 +1,72 @@
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;
+2 -3
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { ChevronIcon, TrashIcon, EditIcon } from '../ui/icons';
import { ChevronIcon, TrashIcon, EditIcon, FolderIcon } from '../ui/icons';
const FOLDER_ICON_SRC = '/folder.svg';
import { getTagColorStyle } from '../utils/colors';
const FolderNode = ({
@@ -68,7 +67,7 @@ const FolderNode = ({
</span>
)}
<span className="name">
<img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
<FolderIcon className="folder-icon-image" size={16} />
{node.name}
</span>
{node.id !== 'root' && (
+2 -63
View File
@@ -10,6 +10,7 @@
flex: 1;
display: flex;
flex-direction: column;
grid-column: 2 / -1;
min-height: 0;
position: relative;
background-color: var(--surface-subtle);
@@ -158,7 +159,7 @@
align-items: center;
justify-content: flex-start;
padding: 0.28rem 0.7rem;
border-radius: 1px;
border-radius: 1rem;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
color: var(--fg);
font-size: 1rem;
@@ -192,68 +193,6 @@
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 * {
cursor: not-allowed !important;
+36 -184
View File
@@ -7,7 +7,7 @@ import React, {
useState,
} from 'react';
import { resolveDocumentAssetUrl } from './asset_manager';
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
import { getReadableTextColor } from './utils/colors';
import './skeuomorphic_ws.css';
const ITEM_WIDTH = 220;
@@ -31,7 +31,6 @@ const ZOOM_MIN_SCALE = 1.05;
const ZOOM_MAX_SCALE = 5;
const TAG_REMOVE_DISTANCE = 160;
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
const DEBUG_DRAG = false;
const DEBUG_FOCUS = true;
const DEBUG_DROP = true;
@@ -211,6 +210,15 @@ const useDocumentDrag = ({
if (!state || state.pointerId !== pointerId) {
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;
setDraggingId((current) => (current === state.docId ? null : current));
syncLayoutSnapshot();
@@ -247,6 +255,15 @@ const useDocumentDrag = ({
}
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 = {
docId,
pointerId: event.pointerId,
@@ -260,6 +277,7 @@ const useDocumentDrag = ({
width: docWidth,
height: docHeight,
scale: baseScale,
capturedTarget,
};
setDraggingId(docId);
},
@@ -432,20 +450,17 @@ const SkeuomorphicWorkspace = ({
onRefresh,
onDocumentOpen,
resolveThumbnailUrl,
availableTags = [],
onCreateTag = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
getDocumentAsset = () => null,
prepareTagPayload = null,
activeTagIds = [],
}) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
const showingSearchResults = searchResults !== null;
const containerRef = useRef(null);
const tagShelfRef = useRef(null);
const layoutRef = useRef(new Map());
const itemRefs = useRef(new Map());
const zCounterRef = useRef(10);
@@ -453,17 +468,27 @@ const SkeuomorphicWorkspace = ({
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const [tagShelfWidth, setTagShelfWidth] = useState(0);
const [draggingId, setDraggingId] = useState(null);
const [zoomedId, setZoomedId] = useState(null);
const [tagDropTargetId, setTagDropTargetId] = useState(null);
const [pendingTagDocId, setPendingTagDocId] = useState(null);
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
const draggingTagRef = useRef(null);
const pendingDocTagDragRef = useRef(null);
const docSizeMapRef = useRef(new Map());
const removalCursorActiveRef = useRef(false);
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(
(doc) => {
@@ -591,72 +616,11 @@ 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(() => {
updateRemovalCursor(false);
setTagDropTargetId(null);
}, [updateRemovalCursor]);
const handleShelfTagDragEndWithReset = useCallback((event) => {
if (event?.currentTarget instanceof HTMLElement) {
const target = event.currentTarget;
const showNode = () => {
if (target instanceof HTMLElement) {
target.classList.remove('is-drag-hidden');
const stored = target[DRAG_PREVIEW_KEY];
cleanupPreview(stored);
delete target[DRAG_PREVIEW_KEY];
}
};
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(showNode);
} else {
setTimeout(showNode, 0);
}
}
handleTagDragEnd();
queueFocusCanvas();
}, [handleTagDragEnd, queueFocusCanvas]);
const ensureDocumentSize = useCallback((doc) => {
const key = resolveSizeKey(doc);
const cache = docSizeMapRef.current.get(key);
@@ -721,17 +685,6 @@ const SkeuomorphicWorkspace = ({
docSizeMapRef.current = new Map();
}, [items]);
useEffect(() => {
if (!activeShelfTagId) {
return;
}
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId);
if (!stillExists) {
setActiveShelfTagId(null);
}
}, [activeShelfTagId, availableTags]);
const resolveZoomMetrics = useCallback(
(doc, cardWidth, cardHeight) => {
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
@@ -821,30 +774,6 @@ const SkeuomorphicWorkspace = ({
[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(() => {
setLayoutSnapshot(new Map(layoutRef.current));
}, []);
@@ -950,7 +879,7 @@ const SkeuomorphicWorkspace = ({
startZ: maxZ,
rotationRange: ROTATION_RANGE,
minSpacing: 48,
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
shelfWidth: 0,
},
);
generatedLayout.forEach((entry, docId) => {
@@ -971,7 +900,6 @@ const SkeuomorphicWorkspace = ({
canvasSize.height,
ensureDocumentSize,
syncLayoutSnapshot,
tagShelfWidth,
]);
useEffect(() => {
@@ -1150,7 +1078,7 @@ const SkeuomorphicWorkspace = ({
setPendingTagDocId(doc.id);
try {
await onAssignTagToDocument({ documentId: doc.id, tagId });
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
markActiveTagDropHandled(tagId, sourceDocId);
if (DEBUG_DROP) {
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
@@ -1504,7 +1432,6 @@ const SkeuomorphicWorkspace = ({
<div
className="skeuo-canvas"
ref={containerRef}
data-active-tag={activeShelfTagId || undefined}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
@@ -1572,7 +1499,7 @@ const SkeuomorphicWorkspace = ({
.map((tag) => resolveTagKey(tag))
.filter(Boolean);
const matchesFilter =
!activeShelfTagId || docTagKeys.includes(activeShelfTagId);
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
const dropActive = tagDropTargetId === doc.id;
const dropPending = pendingTagDocId === doc.id;
const itemClasses = ['skeuo-item'];
@@ -1674,81 +1601,6 @@ 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>
);
+39 -25
View File
@@ -36,6 +36,8 @@
--sidebar-hover-bg: rgba(63, 106, 216, 0.08);
--sidebar-active-bg: rgba(63, 106, 216, 0.16);
--selection-ring: #9bb6ff;
--documents-grid-title-size: 12px;
--documents-grid-icon-size: 128px;
/* Semantic colors */
--success: #2e7d6b;
@@ -749,7 +751,7 @@ button.icon-button.ghost:hover:not([disabled]) {
}
.column + .column {
border-left: 1px solid var(--border);
border-left: none;
padding-left: 1.25rem;
}
@@ -845,6 +847,7 @@ button.icon-button.ghost:hover:not([disabled]) {
color: inherit;
transition: background 0.12s ease, color 0.12s ease;
position: relative;
display: inline-block;
}
.folder-row span.name {
@@ -858,12 +861,6 @@ button.icon-button.ghost:hover:not([disabled]) {
padding-right: 3rem;
}
.folder-row span.name .folder-icon-image {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.folder-row:hover {
background: var(--sidebar-hover-bg);
color: var(--fg);
@@ -945,6 +942,7 @@ button.icon-button.ghost:hover:not([disabled]) {
overflow-y: auto;
padding: 1.25rem;
color: var(--sidebar-fg);
border-right: 1px solid var(--border);
}
.sidebar-section:first-of-type,
@@ -1182,34 +1180,49 @@ button.icon-button.ghost:hover:not([disabled]) {
text-align: center;
}
.document-thumbnail-wrapper {
width: 48px;
height: 48px;
.documents-panel--view-grid .document-thumbnail-wrapper {
width: var(--documents-grid-icon-size);
height: var(--documents-grid-icon-size);
padding: 8px;
border-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
}
.document-thumbnail-inner {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.documents-panel--view-grid .document-thumbnail-wrapper {
width: 128px;
height: 128px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
overflow: hidden;
.document-thumbnail-inner--multipage::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 24px;
height: 24px;
background-image: url('./assets/papercorner.svg');
background-repeat: no-repeat;
background-size: contain;
pointer-events: none;
}
.documents-panel--view-grid .document-thumbnail-inner--multipage::after {
width: 28px;
height: 28px;
}
.document-thumbnail {
max-width: 100%;
max-height: 100%;
object-fit: contain;
box-shadow: 0 1px 6px var(--shadow-medium);
}
.documents-panel--view-grid .document-thumbnail {
box-shadow: 0 1px 12px var(--shadow-medium);
background: transparent;
}
.thumb-placeholder {
@@ -1307,7 +1320,7 @@ button.icon-button.ghost:hover:not([disabled]) {
.documents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(var(--documents-grid-icon-size), 1fr));
gap: 0.3rem 1.3rem;
padding: 0;
}
@@ -1349,6 +1362,7 @@ button.icon-button.ghost:hover:not([disabled]) {
border-radius: 1rem;
max-width: 100%;
word-break: break-word;
font-size: var(--documents-grid-title-size);
}
.document-card.selected .document-card__title {
@@ -1377,8 +1391,8 @@ button.icon-button.ghost:hover:not([disabled]) {
display: flex;
align-items: center;
justify-content: center;
width: 128px;
height: 128px;
width: var(--documents-grid-icon-size);
height: var(--documents-grid-icon-size);
}
.folder-card__icon-svg {
@@ -1399,11 +1413,11 @@ button.icon-button.ghost:hover:not([disabled]) {
overflow: hidden;
text-overflow: ellipsis;
white-space: break-word;
font-size: var(--documents-grid-title-size);
}
.doc-name__title {
font-weight: 600;
max-width: 100%;
word-break: break-word;
}
+14 -7
View File
@@ -1,7 +1,6 @@
import {
IconChevronRight as TablerChevronRight,
IconDownload as TablerDownload,
IconFolderFilled,
IconPencil,
IconTagFilled,
IconUserFilled,
@@ -9,6 +8,7 @@ import {
IconLayoutList,
IconLayoutGrid,
} from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
@@ -39,14 +39,21 @@ export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
/>
);
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconFolderFilled
className={composeClassName('icon icon--fill', className)}
size={size}
stroke={stroke}
export const FolderIcon = ({ className, size = 16, title, ...rest }) => {
const dimensionProps = typeof size === 'number' ? { width: size, height: size } : {};
return (
<FolderSvg
className={composeClassName('folder-icon', className)}
{...dimensionProps}
role={title ? 'img' : 'presentation'}
aria-hidden={title ? undefined : true}
focusable="false"
title={title}
{...rest}
/>
);
);
};
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
<IconTagFilled
+10
View File
@@ -29,6 +29,16 @@ module.exports = {
test: /\.css$/i,
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: [
+11
View File
@@ -0,0 +1,11 @@
# 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