Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047b99e2aa | ||
|
|
1859078cf4 | ||
|
|
c44ba91ec7 | ||
|
|
96a6d0ee5d | ||
|
|
5619bb27a1 | ||
|
|
dfc99c7d15 | ||
|
|
fb82f505b6 | ||
|
|
5970340a17 | ||
|
|
9a77e76ff4 | ||
|
|
3591415d46 | ||
|
|
ab06205744 | ||
|
|
0689e680fa | ||
|
|
297d5aca1f | ||
|
|
0a023c1556 | ||
|
|
aa600af7b2 | ||
|
|
fc4aae8c0c | ||
|
|
24c0b5fa52 | ||
|
|
2af2af3460 |
@@ -1,5 +1,15 @@
|
|||||||
# Papercrate
|
# Papercrate
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux -f papercrate.tmux attach
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates windows for the compose stack, frontend dev server, backend API, and background worker using the repository-relative paths defined in the tmux file. Detach with `Ctrl+b d` and reattach later with the same command.
|
||||||
|
|
||||||
## Backend Integration Tests
|
## Backend Integration Tests
|
||||||
|
|
||||||
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
|
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2,13 +2,14 @@ use std::env;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use backend::{
|
use backend::{
|
||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db,
|
db,
|
||||||
models::DocumentAsset,
|
models::{DocumentAsset, DocumentAssetObject},
|
||||||
s3,
|
s3,
|
||||||
schema::document_assets,
|
schema::{document_asset_objects, document_assets},
|
||||||
storage::{ObjectStorage, S3Storage},
|
storage::{ObjectStorage, S3Storage},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,11 +58,18 @@ async fn delete_all_assets() -> Result<()> {
|
|||||||
|
|
||||||
println!("Deleting {} assets…", assets.len());
|
println!("Deleting {} assets…", assets.len());
|
||||||
|
|
||||||
for asset in &assets {
|
let asset_ids: Vec<Uuid> = assets.iter().map(|asset| asset.id).collect();
|
||||||
if let Err(err) = storage.delete_object(&asset.s3_key).await {
|
|
||||||
|
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
||||||
|
.filter(document_asset_objects::asset_id.eq_any(&asset_ids))
|
||||||
|
.load(&mut conn)
|
||||||
|
.context("failed to load document asset objects")?;
|
||||||
|
|
||||||
|
for object in &objects {
|
||||||
|
if let Err(err) = storage.delete_object(&object.s3_key).await {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Failed to delete object {} from storage: {err}",
|
"Failed to delete object {} from storage: {err}",
|
||||||
asset.s3_key
|
object.s3_key
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-2
@@ -109,10 +109,10 @@ pub struct DocumentAsset {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub document_version_id: Uuid,
|
pub document_version_id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub s3_key: String,
|
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
pub created_at: NaiveDateTime,
|
pub created_at: NaiveDateTime,
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Insertable)]
|
#[derive(Debug, Insertable)]
|
||||||
@@ -121,9 +121,30 @@ pub struct NewDocumentAsset {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub document_version_id: Uuid,
|
pub document_version_id: Uuid,
|
||||||
pub asset_type: String,
|
pub asset_type: String,
|
||||||
pub s3_key: String,
|
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: serde_json::Value,
|
pub metadata: serde_json::Value,
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
|
||||||
|
#[diesel(table_name = document_asset_objects)]
|
||||||
|
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
|
||||||
|
pub struct DocumentAssetObject {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
|
pub s3_key: String,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable)]
|
||||||
|
#[diesel(table_name = document_asset_objects)]
|
||||||
|
pub struct NewDocumentAssetObject {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
|
pub s3_key: String,
|
||||||
|
pub metadata: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Queryable, Identifiable)]
|
#[derive(Debug, Clone, Queryable, Identifiable)]
|
||||||
|
|||||||
+159
-37
@@ -22,12 +22,13 @@ use crate::auth::AuthenticatedUser;
|
|||||||
use crate::error::{AppError, AppResult};
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
|
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT};
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
Correspondent, Document, DocumentAsset, DocumentCorrespondent, DocumentVersion, NewDocument,
|
Correspondent, Document, DocumentAsset, DocumentAssetObject, DocumentCorrespondent,
|
||||||
NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion, Tag,
|
DocumentVersion, NewDocument, NewDocumentCorrespondent, NewDocumentTag, NewDocumentVersion,
|
||||||
|
Tag,
|
||||||
};
|
};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
correspondents, document_assets, document_correspondents, document_tags, document_versions,
|
correspondents, document_asset_objects, document_assets, document_correspondents,
|
||||||
documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
document_tags, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
|
||||||
};
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -108,7 +109,8 @@ pub struct DocumentVersionResponse {
|
|||||||
pub checksum: String,
|
pub checksum: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
pub operations_summary: Value,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub operations_summary: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
@@ -118,8 +120,31 @@ pub struct DocumentAssetResponse {
|
|||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub metadata: Value,
|
pub metadata: Value,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct DocumentAssetObjectResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub ordinal: i32,
|
||||||
|
pub metadata: Value,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DocumentAssetDetailResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub asset_type: String,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub metadata: Value,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cardinality: Option<i32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub objects: Vec<DocumentAssetObjectResponse>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
@@ -335,6 +360,14 @@ pub struct AssignTagsRequest {
|
|||||||
pub tag_ids: Vec<Uuid>,
|
pub tag_ids: Vec<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct AssetObjectsQuery {
|
||||||
|
#[serde(default)]
|
||||||
|
pub start: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_documents(
|
pub async fn list_documents(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(params): Query<DocumentListQuery>,
|
Query(params): Query<DocumentListQuery>,
|
||||||
@@ -605,7 +638,7 @@ pub async fn get_document(
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let assets = load_asset_responses(&state, version_id).await?;
|
let assets = load_asset_responses(&state, version_id).await?;
|
||||||
let version_response = to_version_response(current_version);
|
let version_response = to_version_response(current_version, true);
|
||||||
|
|
||||||
Ok(Json(DocumentDetailResponse {
|
Ok(Json(DocumentDetailResponse {
|
||||||
document: to_document_response(
|
document: to_document_response(
|
||||||
@@ -848,33 +881,70 @@ pub async fn list_document_assets(
|
|||||||
|
|
||||||
pub async fn get_document_asset(
|
pub async fn get_document_asset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
|
Path(asset_id): Path<Uuid>,
|
||||||
) -> AppResult<Json<DocumentAssetResponse>> {
|
Query(query): Query<AssetObjectsQuery>,
|
||||||
|
) -> AppResult<Json<DocumentAssetDetailResponse>> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
let document: Document = documents::table.find(document_id).first(&mut conn)?;
|
|
||||||
if document.deleted_at.is_some() {
|
let asset: DocumentAsset = match document_assets::table
|
||||||
return Err(AppError::not_found());
|
.find(asset_id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()?
|
||||||
|
{
|
||||||
|
Some(asset) => asset,
|
||||||
|
None => return Err(AppError::not_found()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = query.start.unwrap_or(1);
|
||||||
|
let limit = query.limit.unwrap_or(1);
|
||||||
|
if start < 1 {
|
||||||
|
return Err(AppError::bad_request("start must be at least 1"));
|
||||||
|
}
|
||||||
|
if limit < 1 {
|
||||||
|
return Err(AppError::bad_request("limit must be at least 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let asset: DocumentAsset = document_assets::table.find(asset_id).first(&mut conn)?;
|
let end = start
|
||||||
let version: DocumentVersion = document_versions::table
|
.checked_add(limit - 1)
|
||||||
.find(asset.document_version_id)
|
.ok_or_else(|| AppError::bad_request("requested range is too large"))?;
|
||||||
.first(&mut conn)?;
|
|
||||||
|
|
||||||
if version.document_id != document_id {
|
let objects: Vec<DocumentAssetObject> = document_asset_objects::table
|
||||||
return Err(AppError::not_found());
|
.filter(document_asset_objects::asset_id.eq(asset_id))
|
||||||
}
|
.filter(document_asset_objects::ordinal.ge(start))
|
||||||
|
.filter(document_asset_objects::ordinal.le(end))
|
||||||
|
.order(document_asset_objects::ordinal.asc())
|
||||||
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let s3_key = asset.s3_key.clone();
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let presigned_url = state
|
let expires_at = Utc::now()
|
||||||
|
.timestamp_millis()
|
||||||
|
.checked_add((PRESIGNED_URL_EXPIRY_SECONDS as i64) * 1000)
|
||||||
|
.ok_or_else(|| AppError::internal("failed to compute expiry timestamp"))?;
|
||||||
|
|
||||||
|
let mut object_responses = Vec::with_capacity(objects.len());
|
||||||
|
for object in objects {
|
||||||
|
let url = state
|
||||||
.storage
|
.storage
|
||||||
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
|
.presign_get_object(
|
||||||
|
&object.s3_key,
|
||||||
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
|
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
|
||||||
|
|
||||||
Ok(Json(to_asset_response(asset, Some(presigned_url))))
|
object_responses.push(to_asset_object_response(
|
||||||
|
object,
|
||||||
|
Some(url),
|
||||||
|
Some(expires_at),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if object_responses.is_empty() {
|
||||||
|
return Err(AppError::not_found());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(to_asset_detail_response(asset, object_responses)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn download_document(
|
pub async fn download_document(
|
||||||
@@ -1032,7 +1102,7 @@ pub async fn update_document(
|
|||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
let assets = load_asset_responses(&state, version_id).await?;
|
let assets = load_asset_responses(&state, version_id).await?;
|
||||||
let version_response = to_version_response(current_version);
|
let version_response = to_version_response(current_version, true);
|
||||||
|
|
||||||
Ok(Json(DocumentDetailResponse {
|
Ok(Json(DocumentDetailResponse {
|
||||||
document: to_document_response(
|
document: to_document_response(
|
||||||
@@ -1571,7 +1641,7 @@ async fn process_upload(
|
|||||||
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
|
let correspondents = correspondents_map.remove(&document.id).unwrap_or_default();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let assets = load_asset_responses(state, version.id).await?;
|
let assets = load_asset_responses(state, version.id).await?;
|
||||||
let version_response = to_version_response(version.clone());
|
let version_response = to_version_response(version.clone(), true);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
document_id = %document.id,
|
document_id = %document.id,
|
||||||
@@ -1664,7 +1734,7 @@ async fn process_upload(
|
|||||||
document,
|
document,
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
Some((to_version_response(version.clone()), Vec::new())),
|
Some((to_version_response(version.clone(), true), Vec::new())),
|
||||||
)?,
|
)?,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1784,18 +1854,27 @@ pub(crate) async fn load_primary_assets(
|
|||||||
version_map.insert(version.id, version);
|
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))
|
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
||||||
.order((
|
.order((
|
||||||
document_assets::document_version_id.asc(),
|
document_assets::document_version_id.asc(),
|
||||||
document_assets::created_at.asc(),
|
document_assets::created_at.asc(),
|
||||||
))
|
))
|
||||||
|
.select((
|
||||||
|
document_assets::all_columns,
|
||||||
|
document_asset_objects::all_columns.nullable(),
|
||||||
|
))
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
|
|
||||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||||
for asset in assets {
|
for (asset, _object) in assets {
|
||||||
let version_id = asset.document_version_id;
|
let version_id = asset.document_version_id;
|
||||||
let response = to_asset_response(asset, None);
|
let response = to_asset_summary(asset);
|
||||||
assets_by_version
|
assets_by_version
|
||||||
.entry(version_id)
|
.entry(version_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -1809,7 +1888,7 @@ pub(crate) async fn load_primary_assets(
|
|||||||
for (doc_id, version_id) in doc_to_version {
|
for (doc_id, version_id) in doc_to_version {
|
||||||
if let Some(version) = version_map.remove(&version_id) {
|
if let Some(version) = version_map.remove(&version_id) {
|
||||||
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
||||||
result.insert(doc_id, (to_version_response(version), assets));
|
result.insert(doc_id, (to_version_response(version, false), assets));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1865,7 +1944,10 @@ fn build_download_path(state: &AppState, document_id: Uuid, user_id: Uuid) -> Ap
|
|||||||
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
.map_err(|err| AppError::internal(format!("failed to generate download token: {err}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
fn to_version_response(
|
||||||
|
version: DocumentVersion,
|
||||||
|
include_operations_summary: bool,
|
||||||
|
) -> DocumentVersionResponse {
|
||||||
DocumentVersionResponse {
|
DocumentVersionResponse {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
version_number: version.version_number,
|
version_number: version.version_number,
|
||||||
@@ -1874,19 +1956,50 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
|||||||
checksum: version.checksum,
|
checksum: version.checksum,
|
||||||
created_at: to_iso(version.created_at),
|
created_at: to_iso(version.created_at),
|
||||||
metadata: version.metadata,
|
metadata: version.metadata,
|
||||||
operations_summary: version.operations_summary,
|
operations_summary: if include_operations_summary {
|
||||||
|
Some(version.operations_summary)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_asset_response(asset: DocumentAsset, url: Option<String>) -> DocumentAssetResponse {
|
fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse {
|
||||||
let metadata = asset.metadata.clone();
|
|
||||||
DocumentAssetResponse {
|
DocumentAssetResponse {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
asset_type: asset.asset_type,
|
asset_type: asset.asset_type,
|
||||||
mime_type: asset.mime_type,
|
mime_type: asset.mime_type,
|
||||||
metadata,
|
metadata: asset.metadata,
|
||||||
url,
|
cardinality: asset.cardinality,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_asset_detail_response(
|
||||||
|
asset: DocumentAsset,
|
||||||
|
objects: Vec<DocumentAssetObjectResponse>,
|
||||||
|
) -> DocumentAssetDetailResponse {
|
||||||
|
DocumentAssetDetailResponse {
|
||||||
|
id: asset.id,
|
||||||
|
asset_type: asset.asset_type,
|
||||||
|
mime_type: asset.mime_type,
|
||||||
|
metadata: asset.metadata,
|
||||||
created_at: to_iso(asset.created_at),
|
created_at: to_iso(asset.created_at),
|
||||||
|
cardinality: asset.cardinality,
|
||||||
|
objects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_asset_object_response(
|
||||||
|
object: DocumentAssetObject,
|
||||||
|
url: Option<String>,
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
) -> DocumentAssetObjectResponse {
|
||||||
|
DocumentAssetObjectResponse {
|
||||||
|
id: object.id,
|
||||||
|
ordinal: object.ordinal,
|
||||||
|
metadata: object.metadata,
|
||||||
|
url,
|
||||||
|
expires_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1931,15 +2044,24 @@ async fn load_asset_responses(
|
|||||||
version_id: Uuid,
|
version_id: Uuid,
|
||||||
) -> AppResult<Vec<DocumentAssetResponse>> {
|
) -> AppResult<Vec<DocumentAssetResponse>> {
|
||||||
let mut conn = state.db()?;
|
let mut conn = state.db()?;
|
||||||
let assets: Vec<DocumentAsset> = 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))
|
.filter(document_assets::document_version_id.eq(version_id))
|
||||||
.order(document_assets::created_at.asc())
|
.order(document_assets::created_at.asc())
|
||||||
|
.select((
|
||||||
|
document_assets::all_columns,
|
||||||
|
document_asset_objects::all_columns.nullable(),
|
||||||
|
))
|
||||||
.load(&mut conn)?;
|
.load(&mut conn)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
Ok(assets
|
Ok(assets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|asset| to_asset_response(asset, None))
|
.map(|(asset, _object)| to_asset_summary(asset))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
.patch(documents::update_document),
|
.patch(documents::update_document),
|
||||||
)
|
)
|
||||||
.route("/:id/download", get(documents::download_document))
|
.route("/:id/download", get(documents::download_document))
|
||||||
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
|
|
||||||
.route(
|
.route(
|
||||||
"/:id/assets",
|
"/:id/assets",
|
||||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||||
@@ -120,11 +119,14 @@ pub fn create_router(state: AppState) -> Router<()> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let protected_state = state.clone();
|
let protected_state = state.clone();
|
||||||
|
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset));
|
||||||
|
|
||||||
let protected_routes = Router::new()
|
let protected_routes = Router::new()
|
||||||
.nest("/api/documents", documents_routes)
|
.nest("/api/documents", documents_routes)
|
||||||
.nest("/api/folders", folders_routes)
|
.nest("/api/folders", folders_routes)
|
||||||
.nest("/api/tags", tags_routes)
|
.nest("/api/tags", tags_routes)
|
||||||
.nest("/api/correspondents", correspondents_routes)
|
.nest("/api/correspondents", correspondents_routes)
|
||||||
|
.nest("/api/assets", assets_routes)
|
||||||
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
|||||||
+13
-1
@@ -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! {
|
diesel::table! {
|
||||||
document_assets (id) {
|
document_assets (id) {
|
||||||
id -> Uuid,
|
id -> Uuid,
|
||||||
document_version_id -> Uuid,
|
document_version_id -> Uuid,
|
||||||
asset_type -> Text,
|
asset_type -> Text,
|
||||||
s3_key -> Text,
|
|
||||||
mime_type -> Text,
|
mime_type -> Text,
|
||||||
metadata -> Jsonb,
|
metadata -> Jsonb,
|
||||||
created_at -> Timestamptz,
|
created_at -> Timestamptz,
|
||||||
|
cardinality -> Nullable<Int4>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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_assets -> document_versions (document_version_id));
|
||||||
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
|
||||||
diesel::joinable!(document_correspondents -> documents (document_id));
|
diesel::joinable!(document_correspondents -> documents (document_id));
|
||||||
@@ -155,6 +166,7 @@ diesel::joinable!(refresh_tokens -> users (user_id));
|
|||||||
|
|
||||||
diesel::allow_tables_to_appear_in_same_query!(
|
diesel::allow_tables_to_appear_in_same_query!(
|
||||||
correspondents,
|
correspondents,
|
||||||
|
document_asset_objects,
|
||||||
document_assets,
|
document_assets,
|
||||||
document_correspondents,
|
document_correspondents,
|
||||||
document_tags,
|
document_tags,
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
jobs::JOB_INDEX_DOCUMENT_TEXT,
|
||||||
models::{Document, DocumentAsset, DocumentVersion},
|
models::{Document, DocumentVersion},
|
||||||
schema::{document_assets, document_versions, documents},
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
state::AppState,
|
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");
|
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
|
||||||
return JobExecution::Failed {
|
return JobExecution::Failed {
|
||||||
error: "missing OCR text asset".into(),
|
error: "missing OCR text asset".into(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let asset = context.text_asset.unwrap();
|
let s3_key = context.text_s3_key.unwrap();
|
||||||
let text = match state.storage.get_object(&asset.s3_key).await {
|
let text = match state.storage.get_object(&s3_key).await {
|
||||||
Ok(bytes) => match String::from_utf8(bytes) {
|
Ok(bytes) => match String::from_utf8(bytes) {
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -169,7 +169,7 @@ impl JobHandler for IndexDocumentTextJob {
|
|||||||
struct IndexContext {
|
struct IndexContext {
|
||||||
document: Document,
|
document: Document,
|
||||||
version: DocumentVersion,
|
version: DocumentVersion,
|
||||||
text_asset: Option<DocumentAsset>,
|
text_s3_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, 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)
|
.first(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.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::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||||
|
.filter(document_asset_objects::ordinal.eq(1))
|
||||||
|
.select(document_asset_objects::s3_key)
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
@@ -199,6 +204,6 @@ fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexCon
|
|||||||
Ok(IndexContext {
|
Ok(IndexContext {
|
||||||
document,
|
document,
|
||||||
version,
|
version,
|
||||||
text_asset,
|
text_s3_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-13
@@ -19,8 +19,11 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
|
||||||
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
|
models::{
|
||||||
schema::{document_assets, document_versions, documents},
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
|
NewDocumentAssetObject,
|
||||||
|
},
|
||||||
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,11 +126,15 @@ impl JobHandler for GenerateOcrTextJob {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let asset_id = context
|
if context.existing_asset.is_some() {
|
||||||
.existing_asset
|
for object in &context.existing_objects {
|
||||||
.as_ref()
|
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
|
||||||
.map(|asset| asset.id)
|
warn!(job_id = %job.id, error = %err, s3_key = %object.s3_key, "failed to delete existing ocr asset object");
|
||||||
.unwrap_or_else(Uuid::new_v4);
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_id = Uuid::new_v4();
|
||||||
|
|
||||||
let s3_key = format!(
|
let s3_key = format!(
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
@@ -193,6 +200,7 @@ struct OcrContext {
|
|||||||
document: Document,
|
document: Document,
|
||||||
version: DocumentVersion,
|
version: DocumentVersion,
|
||||||
existing_asset: Option<DocumentAsset>,
|
existing_asset: Option<DocumentAsset>,
|
||||||
|
existing_objects: Vec<DocumentAssetObject>,
|
||||||
skip: bool,
|
skip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,29 +226,41 @@ fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrCon
|
|||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let existing: 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::document_version_id.eq(payload.document_version_id))
|
||||||
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let existing_objects: Vec<DocumentAssetObject> = if let Some(asset) = &existing_asset {
|
||||||
|
document_asset_objects::table
|
||||||
|
.filter(document_asset_objects::asset_id.eq(asset.id))
|
||||||
|
.order(document_asset_objects::ordinal.asc())
|
||||||
|
.load(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
let is_pdf = document_is_pdf(&document);
|
let is_pdf = document_is_pdf(&document);
|
||||||
if !is_pdf {
|
if !is_pdf {
|
||||||
return Ok(OcrContext {
|
return Ok(OcrContext {
|
||||||
document,
|
document,
|
||||||
version,
|
version,
|
||||||
existing_asset: existing,
|
existing_asset: existing_asset,
|
||||||
|
existing_objects,
|
||||||
skip: true,
|
skip: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let skip = existing.is_some() && !payload.force;
|
let skip = existing_asset.is_some() && !payload.force;
|
||||||
|
|
||||||
Ok(OcrContext {
|
Ok(OcrContext {
|
||||||
document,
|
document,
|
||||||
version,
|
version,
|
||||||
existing_asset: existing,
|
existing_asset,
|
||||||
|
existing_objects,
|
||||||
skip,
|
skip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -371,16 +391,22 @@ fn persist_ocr_metadata(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
if let Some(existing_asset) = &context.existing_asset {
|
||||||
|
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_asset.id)))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
let new_asset = NewDocumentAsset {
|
let new_asset = NewDocumentAsset {
|
||||||
id: asset_id,
|
id: asset_id,
|
||||||
document_version_id: context.version.id,
|
document_version_id: context.version.id,
|
||||||
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
||||||
s3_key: s3_key.to_string(),
|
|
||||||
mime_type: "text/plain".to_string(),
|
mime_type: "text/plain".to_string(),
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
"source": source,
|
"source": source,
|
||||||
}),
|
}),
|
||||||
|
cardinality: Some(1),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_assets::table)
|
diesel::insert_into(document_assets::table)
|
||||||
@@ -391,9 +417,41 @@ fn persist_ocr_metadata(
|
|||||||
))
|
))
|
||||||
.do_update()
|
.do_update()
|
||||||
.set((
|
.set((
|
||||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
|
||||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||||
|
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
|
||||||
|
))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let existing_object_id: Option<Uuid> = document_asset_objects::table
|
||||||
|
.filter(document_asset_objects::asset_id.eq(asset_id))
|
||||||
|
.filter(document_asset_objects::ordinal.eq(1))
|
||||||
|
.select(document_asset_objects::id)
|
||||||
|
.first(&mut conn)
|
||||||
|
.optional()
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
let object_id = existing_object_id.unwrap_or_else(Uuid::new_v4);
|
||||||
|
|
||||||
|
let new_object = NewDocumentAssetObject {
|
||||||
|
id: object_id,
|
||||||
|
asset_id,
|
||||||
|
ordinal: 1,
|
||||||
|
s3_key: s3_key.to_string(),
|
||||||
|
metadata: json!({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(document_asset_objects::table)
|
||||||
|
.values(&new_object)
|
||||||
|
.on_conflict((
|
||||||
|
document_asset_objects::asset_id,
|
||||||
|
document_asset_objects::ordinal,
|
||||||
|
))
|
||||||
|
.do_update()
|
||||||
|
.set((
|
||||||
|
document_asset_objects::s3_key.eq(excluded(document_asset_objects::s3_key)),
|
||||||
|
document_asset_objects::metadata.eq(excluded(document_asset_objects::metadata)),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
jobs::JOB_GENERATE_THUMBNAILS,
|
jobs::JOB_GENERATE_THUMBNAILS,
|
||||||
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
|
models::{
|
||||||
schema::{document_assets, document_versions, documents},
|
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocumentAsset,
|
||||||
|
NewDocumentAssetObject,
|
||||||
|
},
|
||||||
|
schema::{document_asset_objects, document_assets, document_versions, documents},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,25 +145,34 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let thumbnail_asset_id = initial
|
if initial.existing_preview.is_some() {
|
||||||
.existing_thumbnail
|
for object in &initial.existing_preview_objects {
|
||||||
.as_ref()
|
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
|
||||||
.map(|asset| asset.id)
|
warn!(
|
||||||
.unwrap_or_else(Uuid::new_v4);
|
job_id = %job.id,
|
||||||
let thumbnail_s3_key = format!(
|
error = %err,
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
s3_key = %object.s3_key,
|
||||||
initial.document.id,
|
"failed to delete existing preview object"
|
||||||
initial.version.version_number,
|
|
||||||
THUMBNAIL_ASSET_TYPE,
|
|
||||||
thumbnail_asset_id
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let preview_asset_id = initial
|
if initial.existing_thumbnail.is_some() {
|
||||||
.existing_preview
|
for object in &initial.existing_thumbnail_objects {
|
||||||
.as_ref()
|
if let Err(err) = state.storage.delete_object(&object.s3_key).await {
|
||||||
.map(|asset| asset.id)
|
warn!(
|
||||||
.unwrap_or_else(Uuid::new_v4);
|
job_id = %job.id,
|
||||||
let preview_s3_key = format!(
|
error = %err,
|
||||||
|
s3_key = %object.s3_key,
|
||||||
|
"failed to delete existing thumbnail object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let preview_asset_id = Uuid::new_v4();
|
||||||
|
let preview_base = format!(
|
||||||
"documents/{}/v{}/assets/{}/{}",
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
initial.document.id,
|
initial.document.id,
|
||||||
initial.version.version_number,
|
initial.version.version_number,
|
||||||
@@ -168,60 +180,103 @@ impl JobHandler for GenerateThumbnailsJob {
|
|||||||
preview_asset_id
|
preview_asset_id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let thumbnail_asset_id = Uuid::new_v4();
|
||||||
|
let thumbnail_base = format!(
|
||||||
|
"documents/{}/v{}/assets/{}/{}",
|
||||||
|
initial.document.id,
|
||||||
|
initial.version.version_number,
|
||||||
|
THUMBNAIL_ASSET_TYPE,
|
||||||
|
thumbnail_asset_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut preview_objects: Vec<AssetObjectPersistence> =
|
||||||
|
Vec::with_capacity(generation.preview.objects.len());
|
||||||
|
for (index, image) in generation.preview.objects.iter().enumerate() {
|
||||||
|
if index + 1 > i32::MAX as usize {
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: "too many preview objects".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let ordinal = (index + 1) as i32;
|
||||||
|
let s3_key = format!("{preview_base}/{ordinal}");
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.storage
|
.storage
|
||||||
.put_object(
|
.put_object(
|
||||||
&preview_s3_key,
|
&s3_key,
|
||||||
generation.preview.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/png".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
|
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload preview; retrying");
|
||||||
return JobExecution::Retry {
|
return JobExecution::Retry {
|
||||||
delay: Duration::from_secs(30),
|
delay: Duration::from_secs(30),
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preview_objects.push(AssetObjectPersistence {
|
||||||
|
ordinal,
|
||||||
|
s3_key,
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thumbnail_objects: Vec<AssetObjectPersistence> =
|
||||||
|
Vec::with_capacity(generation.thumbnail.objects.len());
|
||||||
|
for (index, image) in generation.thumbnail.objects.iter().enumerate() {
|
||||||
|
if index + 1 > i32::MAX as usize {
|
||||||
|
return JobExecution::Failed {
|
||||||
|
error: "too many thumbnail objects".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let ordinal = (index + 1) as i32;
|
||||||
|
let s3_key = format!("{thumbnail_base}/{ordinal}");
|
||||||
|
|
||||||
if let Err(err) = state
|
if let Err(err) = state
|
||||||
.storage
|
.storage
|
||||||
.put_object(
|
.put_object(
|
||||||
&thumbnail_s3_key,
|
&s3_key,
|
||||||
generation.thumbnail.image_bytes.clone(),
|
image.image_bytes.clone(),
|
||||||
Some("image/png".into()),
|
Some("image/png".into()),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
|
warn!(job_id = %job.id, error = %err, ordinal, "failed to upload thumbnail; retrying");
|
||||||
return JobExecution::Retry {
|
return JobExecution::Retry {
|
||||||
delay: Duration::from_secs(30),
|
delay: Duration::from_secs(30),
|
||||||
error: err.to_string(),
|
error: err.to_string(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let state_clone = state.clone();
|
thumbnail_objects.push(AssetObjectPersistence {
|
||||||
match task::spawn_blocking(move || {
|
ordinal,
|
||||||
persist_assets_metadata(
|
s3_key,
|
||||||
state_clone,
|
width: image.width,
|
||||||
&initial,
|
height: image.height,
|
||||||
&[
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_persistences = vec![
|
||||||
AssetPersistence {
|
AssetPersistence {
|
||||||
asset_type: PREVIEW_ASSET_TYPE,
|
asset_type: PREVIEW_ASSET_TYPE,
|
||||||
asset_id: preview_asset_id,
|
asset_id: preview_asset_id,
|
||||||
s3_key: &preview_s3_key,
|
objects: preview_objects,
|
||||||
generated: &generation.preview,
|
|
||||||
},
|
},
|
||||||
AssetPersistence {
|
AssetPersistence {
|
||||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||||
asset_id: thumbnail_asset_id,
|
asset_id: thumbnail_asset_id,
|
||||||
s3_key: &thumbnail_s3_key,
|
objects: thumbnail_objects,
|
||||||
generated: &generation.thumbnail,
|
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
)
|
|
||||||
|
let state_clone = state.clone();
|
||||||
|
match task::spawn_blocking(move || {
|
||||||
|
persist_assets_metadata(state_clone, &initial, &asset_persistences)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -250,7 +305,9 @@ struct ThumbnailContext {
|
|||||||
document: Document,
|
document: Document,
|
||||||
version: DocumentVersion,
|
version: DocumentVersion,
|
||||||
existing_thumbnail: Option<DocumentAsset>,
|
existing_thumbnail: Option<DocumentAsset>,
|
||||||
|
existing_thumbnail_objects: Vec<DocumentAssetObject>,
|
||||||
existing_preview: Option<DocumentAsset>,
|
existing_preview: Option<DocumentAsset>,
|
||||||
|
existing_preview_objects: Vec<DocumentAssetObject>,
|
||||||
skip: bool,
|
skip: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,17 +317,27 @@ struct GeneratedImage {
|
|||||||
height: Option<i32>,
|
height: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GeneratedAsset {
|
||||||
|
objects: Vec<GeneratedImage>,
|
||||||
|
}
|
||||||
|
|
||||||
struct GeneratedAssets {
|
struct GeneratedAssets {
|
||||||
thumbnail: GeneratedImage,
|
thumbnail: GeneratedAsset,
|
||||||
preview: GeneratedImage,
|
preview: GeneratedAsset,
|
||||||
page_count: Option<u32>,
|
page_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AssetPersistence<'a> {
|
struct AssetObjectPersistence {
|
||||||
|
ordinal: i32,
|
||||||
|
s3_key: String,
|
||||||
|
width: Option<i32>,
|
||||||
|
height: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AssetPersistence {
|
||||||
asset_type: &'static str,
|
asset_type: &'static str,
|
||||||
asset_id: Uuid,
|
asset_id: Uuid,
|
||||||
s3_key: &'a str,
|
objects: Vec<AssetObjectPersistence>,
|
||||||
generated: &'a GeneratedImage,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_thumbnail_context(
|
fn load_thumbnail_context(
|
||||||
@@ -303,11 +370,27 @@ fn load_thumbnail_context(
|
|||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
let mut existing_thumbnail = None;
|
let mut existing_thumbnail = None;
|
||||||
|
let mut existing_thumbnail_objects: Vec<DocumentAssetObject> = Vec::new();
|
||||||
let mut existing_preview = None;
|
let mut existing_preview = None;
|
||||||
|
let mut existing_preview_objects: Vec<DocumentAssetObject> = Vec::new();
|
||||||
for asset in existing_assets {
|
for asset in existing_assets {
|
||||||
match asset.asset_type.as_str() {
|
match asset.asset_type.as_str() {
|
||||||
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
|
THUMBNAIL_ASSET_TYPE => {
|
||||||
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
|
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);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,13 +400,33 @@ fn load_thumbnail_context(
|
|||||||
return Err("thumbnail generation not supported for this document".into());
|
return Err("thumbnail generation not supported for this document".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
|
let expected_cardinality = expected_asset_cardinality(&document, &version);
|
||||||
|
let preview_cardinality = existing_preview
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|asset| asset.cardinality)
|
||||||
|
.unwrap_or_else(|| existing_preview_objects.len() as i32);
|
||||||
|
let thumbnail_cardinality = existing_thumbnail
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|asset| asset.cardinality)
|
||||||
|
.unwrap_or_else(|| existing_thumbnail_objects.len() as i32);
|
||||||
|
|
||||||
|
let needs_regeneration = preview_cardinality < expected_cardinality
|
||||||
|
|| thumbnail_cardinality < expected_cardinality
|
||||||
|
|| (existing_preview_objects.len() as i32) < expected_cardinality
|
||||||
|
|| (existing_thumbnail_objects.len() as i32) < expected_cardinality;
|
||||||
|
|
||||||
|
let skip = existing_thumbnail.is_some()
|
||||||
|
&& existing_preview.is_some()
|
||||||
|
&& !payload.force
|
||||||
|
&& !needs_regeneration;
|
||||||
|
|
||||||
Ok(ThumbnailContext {
|
Ok(ThumbnailContext {
|
||||||
document,
|
document,
|
||||||
version,
|
version,
|
||||||
existing_thumbnail,
|
existing_thumbnail,
|
||||||
|
existing_thumbnail_objects,
|
||||||
existing_preview,
|
existing_preview,
|
||||||
|
existing_preview_objects,
|
||||||
skip,
|
skip,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -332,18 +435,7 @@ fn generate_preview_and_thumbnail(
|
|||||||
document: &Document,
|
document: &Document,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
) -> Result<GeneratedAssets, String> {
|
) -> Result<GeneratedAssets, String> {
|
||||||
let is_pdf = document
|
let is_pdf = document_is_pdf(document);
|
||||||
.content_type
|
|
||||||
.as_deref()
|
|
||||||
.map(|mime| mime == "application/pdf")
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
document
|
|
||||||
.original_name
|
|
||||||
.rsplit('.')
|
|
||||||
.next()
|
|
||||||
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
});
|
|
||||||
|
|
||||||
if is_pdf {
|
if is_pdf {
|
||||||
let pdf_assets = generate_pdf_assets(bytes)?;
|
let pdf_assets = generate_pdf_assets(bytes)?;
|
||||||
@@ -362,7 +454,7 @@ fn generate_preview_and_thumbnail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedAsset, GeneratedAsset), String> {
|
||||||
let reader = ImageReader::new(Cursor::new(bytes))
|
let reader = ImageReader::new(Cursor::new(bytes))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
@@ -384,12 +476,19 @@ fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage
|
|||||||
let preview = encode_dynamic_image(preview_image)?;
|
let preview = encode_dynamic_image(preview_image)?;
|
||||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||||
|
|
||||||
Ok((preview, thumbnail))
|
Ok((
|
||||||
|
GeneratedAsset {
|
||||||
|
objects: vec![preview],
|
||||||
|
},
|
||||||
|
GeneratedAsset {
|
||||||
|
objects: vec![thumbnail],
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PdfGeneratedAssets {
|
struct PdfGeneratedAssets {
|
||||||
preview: GeneratedImage,
|
preview: GeneratedAsset,
|
||||||
thumbnail: GeneratedImage,
|
thumbnail: GeneratedAsset,
|
||||||
page_count: u32,
|
page_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,11 +501,7 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
|||||||
.map_err(|err| format!("load pdf: {err}"))?;
|
.map_err(|err| format!("load pdf: {err}"))?;
|
||||||
|
|
||||||
let pages = document.pages();
|
let pages = document.pages();
|
||||||
let total_pages = pages.len();
|
let total_pages = pages.len() as usize;
|
||||||
|
|
||||||
let page = pages
|
|
||||||
.get(0)
|
|
||||||
.map_err(|err| format!("load first page: {err}"))?;
|
|
||||||
|
|
||||||
let render_config = PdfRenderConfig::new()
|
let render_config = PdfRenderConfig::new()
|
||||||
.set_target_width(PREVIEW_WIDTH as i32)
|
.set_target_width(PREVIEW_WIDTH as i32)
|
||||||
@@ -414,30 +509,44 @@ fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
|
|||||||
.render_form_data(true)
|
.render_form_data(true)
|
||||||
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
||||||
|
|
||||||
|
let mut preview_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||||
|
let mut thumbnail_objects: Vec<GeneratedImage> = Vec::with_capacity(total_pages);
|
||||||
|
|
||||||
|
for page_index in 0..total_pages {
|
||||||
|
let page = pages
|
||||||
|
.get(u16::try_from(page_index).map_err(|_| "page index overflow".to_string())?)
|
||||||
|
.map_err(|err| format!("load page {page_index}: {err}"))?;
|
||||||
|
|
||||||
let bitmap = page
|
let bitmap = page
|
||||||
.render_with_config(&render_config)
|
.render_with_config(&render_config)
|
||||||
.map_err(|err| format!("render pdf page: {err}"))?;
|
.map_err(|err| format!("render pdf page {page_index}: {err}"))?;
|
||||||
|
|
||||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||||
|
|
||||||
let thumbnail_image =
|
let thumbnail_image = if preview_image.width() > THUMBNAIL_WIDTH
|
||||||
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
|| preview_image.height() > THUMBNAIL_HEIGHT
|
||||||
|
{
|
||||||
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||||
} else {
|
} else {
|
||||||
preview_image.clone()
|
preview_image.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let preview = encode_dynamic_image(preview_image)?;
|
preview_objects.push(encode_dynamic_image(preview_image)?);
|
||||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
thumbnail_objects.push(encode_dynamic_image(thumbnail_image)?);
|
||||||
|
}
|
||||||
|
|
||||||
let page_count: u32 = total_pages
|
let page_count: u32 = total_pages
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| "page count exceeds supported range".to_string())?;
|
.map_err(|_| "page count exceeds supported range".to_string())?;
|
||||||
|
|
||||||
Ok(PdfGeneratedAssets {
|
Ok(PdfGeneratedAssets {
|
||||||
preview,
|
preview: GeneratedAsset {
|
||||||
thumbnail,
|
objects: preview_objects,
|
||||||
|
},
|
||||||
|
thumbnail: GeneratedAsset {
|
||||||
|
objects: thumbnail_objects,
|
||||||
|
},
|
||||||
page_count,
|
page_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -458,22 +567,47 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
|
|||||||
fn persist_assets_metadata(
|
fn persist_assets_metadata(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
context: &ThumbnailContext,
|
context: &ThumbnailContext,
|
||||||
assets: &[AssetPersistence<'_>],
|
assets: &[AssetPersistence],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
if let Some(existing_preview) = &context.existing_preview {
|
||||||
|
diesel::delete(document_assets::table.filter(document_assets::id.eq(existing_preview.id)))
|
||||||
|
.execute(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing_thumbnail) = &context.existing_thumbnail {
|
||||||
|
diesel::delete(
|
||||||
|
document_assets::table.filter(document_assets::id.eq(existing_thumbnail.id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
for asset in assets {
|
for asset in assets {
|
||||||
|
if asset.objects.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"asset {} has no generated objects",
|
||||||
|
asset.asset_type
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let object_count: i32 = asset
|
||||||
|
.objects
|
||||||
|
.len()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "asset contains too many objects".to_string())?;
|
||||||
|
|
||||||
let new_asset = NewDocumentAsset {
|
let new_asset = NewDocumentAsset {
|
||||||
id: asset.asset_id,
|
id: asset.asset_id,
|
||||||
document_version_id: context.version.id,
|
document_version_id: context.version.id,
|
||||||
asset_type: asset.asset_type.to_string(),
|
asset_type: asset.asset_type.to_string(),
|
||||||
s3_key: asset.s3_key.to_string(),
|
|
||||||
mime_type: "image/png".to_string(),
|
mime_type: "image/png".to_string(),
|
||||||
metadata: json!({
|
metadata: json!({
|
||||||
"generated_at": Utc::now().to_rfc3339(),
|
"generated_at": Utc::now().to_rfc3339(),
|
||||||
"width": asset.generated.width,
|
|
||||||
"height": asset.generated.height,
|
|
||||||
}),
|
}),
|
||||||
|
cardinality: Some(object_count),
|
||||||
};
|
};
|
||||||
|
|
||||||
diesel::insert_into(document_assets::table)
|
diesel::insert_into(document_assets::table)
|
||||||
@@ -484,12 +618,44 @@ fn persist_assets_metadata(
|
|||||||
))
|
))
|
||||||
.do_update()
|
.do_update()
|
||||||
.set((
|
.set((
|
||||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
|
||||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||||
|
document_assets::cardinality.eq(excluded(document_assets::cardinality)),
|
||||||
))
|
))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.map_err(|err| format!("{err:?}"))?;
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
diesel::delete(
|
||||||
|
document_asset_objects::table
|
||||||
|
.filter(document_asset_objects::asset_id.eq(asset.asset_id)),
|
||||||
|
)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
|
||||||
|
for object in &asset.objects {
|
||||||
|
let mut metadata_map = Map::new();
|
||||||
|
if let Some(width) = object.width {
|
||||||
|
metadata_map.insert("width".to_string(), Value::from(width));
|
||||||
|
}
|
||||||
|
if let Some(height) = object.height {
|
||||||
|
metadata_map.insert("height".to_string(), Value::from(height));
|
||||||
|
}
|
||||||
|
|
||||||
|
let object_metadata = Value::Object(metadata_map);
|
||||||
|
|
||||||
|
let new_object = NewDocumentAssetObject {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
ordinal: object.ordinal,
|
||||||
|
s3_key: object.s3_key.clone(),
|
||||||
|
metadata: object_metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
diesel::insert_into(document_asset_objects::table)
|
||||||
|
.values(&new_object)
|
||||||
|
.execute(&mut conn)
|
||||||
|
.map_err(|err| format!("{err:?}"))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -533,3 +699,37 @@ fn persist_document_page_count(
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn document_is_pdf(document: &Document) -> bool {
|
||||||
|
document
|
||||||
|
.content_type
|
||||||
|
.as_deref()
|
||||||
|
.map(|mime| mime.eq_ignore_ascii_case("application/pdf"))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
document
|
||||||
|
.original_name
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expected_asset_cardinality(document: &Document, version: &DocumentVersion) -> i32 {
|
||||||
|
if let Value::Object(map) = &version.metadata {
|
||||||
|
if let Some(count) = map.get("page_count").and_then(|v| v.as_i64()) {
|
||||||
|
if count > 0 {
|
||||||
|
return count
|
||||||
|
.min(i64::from(i32::MAX))
|
||||||
|
.try_into()
|
||||||
|
.unwrap_or(i32::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if document_is_pdf(document) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ Document Assets
|
|||||||
---------------
|
---------------
|
||||||
- GET /api/documents/:id/assets - List generated assets for the current version.
|
- GET /api/documents/:id/assets - List generated assets for the current version.
|
||||||
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
|
- POST /api/documents/:id/assets - Request (re)generation of document assets; accepts optional `force` query flag.
|
||||||
- GET /api/documents/:id/assets/:asset_id - Fetch metadata and a pre-signed URL for a specific asset.
|
- GET /api/assets/:asset_id - Fetch asset metadata plus a presigned URL for a range of objects (query params: `start` and `limit`, defaulting to the first object).
|
||||||
|
|
||||||
Downloads
|
Downloads
|
||||||
---------
|
---------
|
||||||
|
|||||||
+1
-1
@@ -44,4 +44,4 @@ npm run build
|
|||||||
|
|
||||||
## Assets
|
## 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/).
|
||||||
|
|||||||
Generated
+727
@@ -18,6 +18,7 @@
|
|||||||
"@babel/core": "7.26.0",
|
"@babel/core": "7.26.0",
|
||||||
"@babel/preset-env": "7.26.0",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
|
"@svgr/webpack": "8.1.0",
|
||||||
"babel-loader": "9.2.1",
|
"babel-loader": "9.2.1",
|
||||||
"css-loader": "7.1.2",
|
"css-loader": "7.1.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
@@ -641,6 +642,22 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/plugin-syntax-typescript": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/plugin-syntax-unicode-sets-regex": {
|
"node_modules/@babel/plugin-syntax-unicode-sets-regex": {
|
||||||
"version": "7.18.6",
|
"version": "7.18.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
|
||||||
@@ -1300,6 +1317,22 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/plugin-transform-react-constant-elements": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/plugin-transform-react-display-name": {
|
"node_modules/@babel/plugin-transform-react-display-name": {
|
||||||
"version": "7.28.0",
|
"version": "7.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz",
|
||||||
@@ -1499,6 +1532,26 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/plugin-transform-typescript": {
|
||||||
|
"version": "7.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.0.tgz",
|
||||||
|
"integrity": "sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-annotate-as-pure": "^7.27.3",
|
||||||
|
"@babel/helper-create-class-features-plugin": "^7.27.1",
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
|
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
|
||||||
|
"@babel/plugin-syntax-typescript": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/plugin-transform-unicode-escapes": {
|
"node_modules/@babel/plugin-transform-unicode-escapes": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
|
||||||
@@ -1696,6 +1749,26 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/preset-typescript": {
|
||||||
|
"version": "7.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz",
|
||||||
|
"integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-plugin-utils": "^7.27.1",
|
||||||
|
"@babel/helper-validator-option": "^7.27.1",
|
||||||
|
"@babel/plugin-syntax-jsx": "^7.27.1",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.27.1",
|
||||||
|
"@babel/plugin-transform-typescript": "^7.27.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
@@ -1964,6 +2037,290 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-add-jsx-attribute": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-remove-jsx-attribute": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-svg-dynamic-title": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-svg-em-dimensions": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-transform-react-native-svg": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-plugin-transform-svg-component": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/babel-preset": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@svgr/babel-plugin-add-jsx-attribute": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-remove-jsx-attribute": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-svg-dynamic-title": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-svg-em-dimensions": "8.0.0",
|
||||||
|
"@svgr/babel-plugin-transform-react-native-svg": "8.1.0",
|
||||||
|
"@svgr/babel-plugin-transform-svg-component": "8.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/core": "^7.0.0-0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/core": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.21.3",
|
||||||
|
"@svgr/babel-preset": "8.1.0",
|
||||||
|
"camelcase": "^6.2.0",
|
||||||
|
"cosmiconfig": "^8.1.3",
|
||||||
|
"snake-case": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/hast-util-to-babel-ast": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.21.3",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/plugin-jsx": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.21.3",
|
||||||
|
"@svgr/babel-preset": "8.1.0",
|
||||||
|
"@svgr/hast-util-to-babel-ast": "8.0.0",
|
||||||
|
"svg-parser": "^2.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@svgr/core": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/plugin-svgo": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cosmiconfig": "^8.1.3",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"svgo": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@svgr/core": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@svgr/webpack": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.21.3",
|
||||||
|
"@babel/plugin-transform-react-constant-elements": "^7.21.3",
|
||||||
|
"@babel/preset-env": "^7.20.2",
|
||||||
|
"@babel/preset-react": "^7.18.6",
|
||||||
|
"@babel/preset-typescript": "^7.21.0",
|
||||||
|
"@svgr/core": "8.1.0",
|
||||||
|
"@svgr/plugin-jsx": "8.1.0",
|
||||||
|
"@svgr/plugin-svgo": "8.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tabler/icons": {
|
"node_modules/@tabler/icons": {
|
||||||
"version": "3.11.0",
|
"version": "3.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz",
|
||||||
@@ -1990,6 +2347,16 @@
|
|||||||
"react": ">= 16"
|
"react": ">= 16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@trysound/sax": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/body-parser": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -2595,6 +2962,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -2929,6 +3303,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/callsites": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/camel-case": {
|
"node_modules/camel-case": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||||
@@ -2940,6 +3324,19 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001749",
|
"version": "1.0.30001749",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz",
|
||||||
@@ -3170,6 +3567,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cosmiconfig": {
|
||||||
|
"version": "8.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||||
|
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"import-fresh": "^3.3.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"parse-json": "^5.2.0",
|
||||||
|
"path-type": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/d-fischer"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.9.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3238,6 +3662,20 @@
|
|||||||
"url": "https://github.com/sponsors/fb55"
|
"url": "https://github.com/sponsors/fb55"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.0.30",
|
||||||
|
"source-map-js": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/css-what": {
|
"node_modules/css-what": {
|
||||||
"version": "6.2.2",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
@@ -3264,6 +3702,42 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/csso": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "~2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csso/node_modules/css-tree": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.0.28",
|
||||||
|
"source-map-js": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csso/node_modules/mdn-data": {
|
||||||
|
"version": "2.0.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz",
|
||||||
|
"integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -3274,6 +3748,16 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/default-browser": {
|
"node_modules/default-browser": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz",
|
||||||
@@ -3535,6 +4019,16 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/error-ex": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -4447,6 +4941,33 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/import-fresh": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parent-module": "^1.0.0",
|
||||||
|
"resolve-from": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/import-fresh/node_modules/resolve-from": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-local": {
|
"node_modules/import-local": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
|
||||||
@@ -4494,6 +5015,13 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arrayish": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -4691,6 +5219,19 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-yaml": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -4752,6 +5293,13 @@
|
|||||||
"shell-quote": "^1.8.3"
|
"shell-quote": "^1.8.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lines-and-columns": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loader-runner": {
|
"node_modules/loader-runner": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||||
@@ -4834,6 +5382,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||||
|
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@@ -5183,6 +5738,38 @@
|
|||||||
"tslib": "^2.0.3"
|
"tslib": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parent-module": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"callsites": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse-json": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.0.0",
|
||||||
|
"error-ex": "^1.3.1",
|
||||||
|
"json-parse-even-better-errors": "^2.3.0",
|
||||||
|
"lines-and-columns": "^1.1.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -5238,6 +5825,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-type": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6117,6 +6714,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/snake-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dot-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sockjs": {
|
"node_modules/sockjs": {
|
||||||
"version": "0.3.24",
|
"version": "0.3.24",
|
||||||
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
|
||||||
@@ -6321,6 +6929,125 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-parser": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/svgo": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@trysound/sax": "0.2.0",
|
||||||
|
"commander": "^7.2.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-tree": "^2.3.1",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"csso": "^5.0.5",
|
||||||
|
"picocolors": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"svgo": "bin/svgo"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/svgo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svgo/node_modules/commander": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svgo/node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svgo/node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svgo/node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svgo/node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/svgo/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"style-loader": "4.0.0",
|
"style-loader": "4.0.0",
|
||||||
"webpack": "5.95.0",
|
"webpack": "5.95.0",
|
||||||
"webpack-cli": "5.1.4",
|
"webpack-cli": "5.1.4",
|
||||||
"webpack-dev-server": "5.1.0"
|
"webpack-dev-server": "5.1.0",
|
||||||
|
"@svgr/webpack": "8.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
+214
-21
@@ -17,7 +17,116 @@ export const getAssetFromVersion = (currentVersion, assetType) => {
|
|||||||
return getAssetFromGroup(currentVersion.assets, assetType);
|
return getAssetFromGroup(currentVersion.assets, assetType);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, ensureOptions } = {}) => {
|
const normalizeAssetObjects = (objects) => {
|
||||||
|
if (!Array.isArray(objects)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return objects
|
||||||
|
.filter((entry) => Number.isInteger(entry?.ordinal))
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.ordinal - b.ordinal);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeAssetObjects = (existingObjects, incomingObjects) => {
|
||||||
|
const merged = new Map();
|
||||||
|
|
||||||
|
normalizeAssetObjects(existingObjects).forEach((entry) => {
|
||||||
|
merged.set(entry.ordinal, { ...entry });
|
||||||
|
});
|
||||||
|
|
||||||
|
normalizeAssetObjects(incomingObjects).forEach((entry) => {
|
||||||
|
const current = merged.get(entry.ordinal) || {};
|
||||||
|
merged.set(entry.ordinal, { ...current, ...entry });
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...merged.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, value]) => value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AssetView {
|
||||||
|
constructor(asset) {
|
||||||
|
this.asset = asset || null;
|
||||||
|
this._objectsRef = null;
|
||||||
|
this._sortedObjects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardinality() {
|
||||||
|
if (!this.asset) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reported = Number(this.asset.cardinality);
|
||||||
|
if (Number.isFinite(reported) && reported > 0) {
|
||||||
|
return reported;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectsCount = this.getObjects().length;
|
||||||
|
if (objectsCount > 0) {
|
||||||
|
return objectsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.asset.metadata ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getObjects() {
|
||||||
|
if (!this.asset || !Array.isArray(this.asset.objects) || this.asset.objects.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._objectsRef === this.asset.objects) {
|
||||||
|
return this._sortedObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._objectsRef = this.asset.objects;
|
||||||
|
this._sortedObjects = normalizeAssetObjects(this.asset.objects);
|
||||||
|
return this._sortedObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
getObject(ordinal = 1) {
|
||||||
|
const fromObjects = this.getObjects().find((entry) => entry.ordinal === ordinal);
|
||||||
|
if (fromObjects) {
|
||||||
|
return fromObjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ordinal === 1 && this.asset) {
|
||||||
|
if (this.asset.url || this.asset.metadata) {
|
||||||
|
return {
|
||||||
|
ordinal: 1,
|
||||||
|
url: this.asset.url || null,
|
||||||
|
metadata: this.asset.metadata || null,
|
||||||
|
expires_at: this.asset.expiresAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryObject() {
|
||||||
|
return this.getObject(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryMetadata() {
|
||||||
|
return this.getPrimaryObject()?.metadata || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrimaryUrl() {
|
||||||
|
return this.getPrimaryObject()?.url || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasObject(ordinal) {
|
||||||
|
return Boolean(this.getObject(ordinal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAssetView = (asset) => new AssetView(asset);
|
||||||
|
|
||||||
|
export const resolveDocumentAssetUrl = (
|
||||||
|
doc,
|
||||||
|
type,
|
||||||
|
{ ensureAssetUrl, getAsset, ensureOptions, objectOrdinal = 1 } = {},
|
||||||
|
) => {
|
||||||
if (!doc || !type) {
|
if (!doc || !type) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -25,15 +134,32 @@ export const resolveDocumentAssetUrl = (doc, type, { ensureAssetUrl, getAsset, e
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const view = createAssetView(asset);
|
||||||
|
const object = view.getObject(objectOrdinal);
|
||||||
|
const url = object?.url || (objectOrdinal === 1 ? view.getPrimaryUrl() : null);
|
||||||
|
const expiresAt = typeof object?.expires_at === 'number'
|
||||||
|
? object.expires_at
|
||||||
|
: objectOrdinal === 1 && typeof asset.expiresAt === 'number'
|
||||||
|
? asset.expiresAt
|
||||||
|
: null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
if (url && (!expiresAt || expiresAt > now)) {
|
||||||
const hasFreshUrl = asset.url && (!expiresAt || expiresAt > now);
|
return url;
|
||||||
if (hasFreshUrl) {
|
|
||||||
return asset.url;
|
|
||||||
}
|
}
|
||||||
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
if (doc.id && asset.id && typeof ensureAssetUrl === 'function') {
|
||||||
const force = Boolean(asset.url && expiresAt && expiresAt <= now);
|
const force = Boolean(url && expiresAt && expiresAt <= now);
|
||||||
const options = ensureOptions ? { ...ensureOptions, force } : { force };
|
const options = {
|
||||||
|
force,
|
||||||
|
start: objectOrdinal,
|
||||||
|
limit: 1,
|
||||||
|
...(ensureOptions || {}),
|
||||||
|
};
|
||||||
|
if (!options.start) {
|
||||||
|
options.start = objectOrdinal;
|
||||||
|
}
|
||||||
|
if (!options.limit) {
|
||||||
|
options.limit = 1;
|
||||||
|
}
|
||||||
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
ensureAssetUrl(doc.id, asset, options).catch(() => {});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -63,6 +189,10 @@ class AssetManager {
|
|||||||
}
|
}
|
||||||
const cached = this.assetCache.get(asset.id);
|
const cached = this.assetCache.get(asset.id);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
|
const normalized = mergeAssetObjects(null, asset.objects);
|
||||||
|
if (normalized.length) {
|
||||||
|
return { ...asset, objects: normalized };
|
||||||
|
}
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
const merged = { ...cached, ...asset };
|
const merged = { ...cached, ...asset };
|
||||||
@@ -76,6 +206,10 @@ class AssetManager {
|
|||||||
merged.expiresAt = cachedExpires;
|
merged.expiresAt = cachedExpires;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const mergedObjects = mergeAssetObjects(cached.objects, asset.objects);
|
||||||
|
if (mergedObjects.length) {
|
||||||
|
merged.objects = mergedObjects;
|
||||||
|
}
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,24 +306,53 @@ class AssetManager {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAsset(documentId, asset, { force = false } = {}) {
|
ensureAsset(documentId, asset, { force = false, start = null, limit = null } = {}) {
|
||||||
if (!documentId || !asset?.id) {
|
if (!documentId || !asset?.id) {
|
||||||
return Promise.resolve(asset || null);
|
return Promise.resolve(asset || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
const requestedStart = Number.isInteger(start) && start > 0 ? start : 1;
|
||||||
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
|
const requestedLimit = Number.isInteger(limit) && limit > 0 ? limit : 1;
|
||||||
this.rememberAsset(asset);
|
const requestedEnd = requestedStart + requestedLimit - 1;
|
||||||
return Promise.resolve(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cached = this.assetCache.get(asset.id);
|
const baseAsset = this.assetCache.get(asset.id) || asset;
|
||||||
|
const view = createAssetView(baseAsset);
|
||||||
|
const assetExpiresAt = typeof baseAsset.expiresAt === 'number' ? baseAsset.expiresAt : null;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
|
|
||||||
return Promise.resolve({ ...asset, ...cached });
|
const isOrdinalSatisfied = (ordinal) => {
|
||||||
|
const object = view.getObject(ordinal);
|
||||||
|
if (!object) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!object.url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof object.expires_at === 'number') {
|
||||||
|
return object.expires_at > now;
|
||||||
|
}
|
||||||
|
if (ordinal === 1 && baseAsset.url && (!assetExpiresAt || assetExpiresAt > now)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let needsFetch = force;
|
||||||
|
if (!needsFetch) {
|
||||||
|
for (let ordinal = requestedStart; ordinal <= requestedEnd; ordinal += 1) {
|
||||||
|
if (!isOrdinalSatisfied(ordinal)) {
|
||||||
|
needsFetch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inflightKey = `${documentId}:${asset.id}`;
|
if (!needsFetch) {
|
||||||
|
this.rememberAsset(baseAsset);
|
||||||
|
return Promise.resolve(baseAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflightKey = `${documentId}:${asset.id}:${start ?? 'd'}:${limit ?? 'd'}`;
|
||||||
if (!force && this.assetInflight.has(inflightKey)) {
|
if (!force && this.assetInflight.has(inflightKey)) {
|
||||||
return this.assetInflight.get(inflightKey);
|
return this.assetInflight.get(inflightKey);
|
||||||
}
|
}
|
||||||
@@ -198,14 +361,44 @@ class AssetManager {
|
|||||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
if (Number.isInteger(start) && start > 0) {
|
||||||
|
params.start = start;
|
||||||
|
}
|
||||||
|
if (Number.isInteger(limit) && limit > 0) {
|
||||||
|
params.limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestConfig = Object.keys(params).length ? { params } : undefined;
|
||||||
|
|
||||||
const request = this.api
|
const request = this.api
|
||||||
.get(`/documents/${documentId}/assets/${asset.id}`)
|
.get(`/assets/${asset.id}`, requestConfig)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
|
const incomingObjects = Array.isArray(data.objects) ? data.objects : [];
|
||||||
|
const cachedEntry = this.assetCache.get(asset.id) || baseAsset;
|
||||||
|
const mergedObjects = mergeAssetObjects(cachedEntry?.objects, incomingObjects);
|
||||||
|
const combined = { ...cachedEntry, ...asset, ...data, objects: mergedObjects };
|
||||||
|
const view = createAssetView(combined);
|
||||||
|
const primaryObject = view.getPrimaryObject();
|
||||||
|
const expiresAt = typeof primaryObject?.expires_at === 'number'
|
||||||
|
? primaryObject.expires_at
|
||||||
|
: Date.now() + this.assetPresignTtlMs;
|
||||||
|
const cardinality = (() => {
|
||||||
|
const reported = Number(data.cardinality ?? asset.cardinality ?? cachedEntry?.cardinality);
|
||||||
|
const objectsCount = mergedObjects.length;
|
||||||
|
if (Number.isFinite(reported) && reported > 0) {
|
||||||
|
return Math.max(reported, objectsCount) || null;
|
||||||
|
}
|
||||||
|
return objectsCount || null;
|
||||||
|
})();
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
...asset,
|
...combined,
|
||||||
...data,
|
cardinality,
|
||||||
expiresAt: Date.now() + this.assetPresignTtlMs,
|
url: view.getPrimaryUrl(),
|
||||||
|
expiresAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.rememberAsset(entry);
|
this.rememberAsset(entry);
|
||||||
return entry;
|
return entry;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<linearGradient id="a" gradientTransform="matrix(0.45451 0 0 0.455522 -1210.292114 616.172607)" gradientUnits="userSpaceOnUse" x1="2689.251953" x2="2918.069824" y1="-1106.802979" y2="-1106.802979">
|
||||||
<stop offset="0" stop-color="#62a0ea"/>
|
<stop offset="0" stop-color="#62a0ea"/>
|
||||||
<stop offset="0.0576991" stop-color="#afd4ff"/>
|
<stop offset="0.0576991" stop-color="#afd4ff"/>
|
||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -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 |
@@ -1,15 +1,25 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { DownloadIcon, EditIcon } from '../ui/icons';
|
import { DownloadIcon, EditIcon, ArrowLeftIcon, ArrowRightIcon } from '../ui/icons';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { formatFileSize } from '../utils/format';
|
import { formatFileSize } from '../utils/format';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
|
import { useAssetNavigator } from '../hooks/useAssetNavigator';
|
||||||
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
import { CORRESPONDENT_ROLES } from '../constants/correspondents';
|
||||||
|
|
||||||
const MAX_PREVIEW_STACK_ITEMS = 15;
|
const MAX_PREVIEW_STACK_ITEMS = 15;
|
||||||
|
|
||||||
const normalizeRole = (role) => (role || '').toLowerCase();
|
const normalizeRole = (role) => (role || '').toLowerCase();
|
||||||
|
|
||||||
|
const derivePreviewOrientation = (metadata) => {
|
||||||
|
const width = Number(metadata?.width);
|
||||||
|
const height = Number(metadata?.height);
|
||||||
|
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
||||||
|
return width >= height ? 'landscape' : 'portrait';
|
||||||
|
}
|
||||||
|
return 'landscape';
|
||||||
|
};
|
||||||
|
|
||||||
const formatRoleLabel = (role) => {
|
const formatRoleLabel = (role) => {
|
||||||
const normalized = normalizeRole(role);
|
const normalized = normalizeRole(role);
|
||||||
if (!normalized) return 'Other';
|
if (!normalized) return 'Other';
|
||||||
@@ -435,20 +445,34 @@ const DetailPanel = ({
|
|||||||
setOcrOpen(false);
|
setOcrOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const makePreviewItem = useCallback(
|
const singlePreviewNavigator = useAssetNavigator({
|
||||||
(doc) => {
|
document: singleDoc,
|
||||||
if (!doc) return null;
|
assetType: 'preview',
|
||||||
const url = resolveDocumentAssetUrl(doc, 'preview', {
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getAsset: getDocumentAsset,
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makePreviewItem = useCallback(
|
||||||
|
(doc, ordinal = 1) => {
|
||||||
|
if (!doc) return null;
|
||||||
|
const asset = getDocumentAsset(doc, 'preview');
|
||||||
|
const assetView = createAssetView(asset);
|
||||||
|
const object = assetView.getObject(ordinal);
|
||||||
|
let url = object?.url || null;
|
||||||
|
if (!url) {
|
||||||
|
url = resolveDocumentAssetUrl(doc, 'preview', {
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
ensureOptions: { start: ordinal, limit: 1 },
|
||||||
|
objectOrdinal: ordinal,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const asset = getDocumentAsset(doc, 'preview');
|
const metadata = object?.metadata || assetView.getPrimaryMetadata() || {};
|
||||||
const width = Number(asset?.metadata?.width) || 0;
|
const orientation = derivePreviewOrientation(metadata);
|
||||||
const height = Number(asset?.metadata?.height) || 0;
|
|
||||||
const orientation = width > 0 && height > 0 ? (width >= height ? 'landscape' : 'portrait') : 'landscape';
|
|
||||||
return {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
url,
|
url,
|
||||||
@@ -475,32 +499,74 @@ const DetailPanel = ({
|
|||||||
return ordered;
|
return ordered;
|
||||||
}, [selectedDocuments]);
|
}, [selectedDocuments]);
|
||||||
|
|
||||||
const singlePreviewItems = useMemo(() => {
|
const stackTopDocument = stackDocuments[0] || null;
|
||||||
if (!singleDoc) return [];
|
const stackPreviewNavigator = useAssetNavigator({
|
||||||
const item = makePreviewItem(singleDoc);
|
document: stackTopDocument,
|
||||||
return item ? [item] : [];
|
assetType: 'preview',
|
||||||
}, [singleDoc, makePreviewItem]);
|
|
||||||
|
|
||||||
const stackPreviews = useMemo(
|
|
||||||
() =>
|
|
||||||
stackDocuments
|
|
||||||
.map((doc) => makePreviewItem(doc))
|
|
||||||
.filter(Boolean),
|
|
||||||
[stackDocuments, makePreviewItem],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ensureAssetUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stackDocuments.forEach((doc) => {
|
|
||||||
resolveDocumentAssetUrl(doc, 'preview', {
|
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getAsset: getDocumentAsset,
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch: 3,
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}, [stackDocuments, ensureAssetUrl, getDocumentAsset]);
|
const singlePreviewItems = useMemo(() => {
|
||||||
|
if (!singleDoc) return [];
|
||||||
|
const url = singlePreviewNavigator.currentUrl;
|
||||||
|
if (!url) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const orientation = derivePreviewOrientation(singlePreviewNavigator.currentMetadata);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: singleDoc.id,
|
||||||
|
url,
|
||||||
|
orientation,
|
||||||
|
alt: singleDoc.title || singleDoc.original_name || 'Document preview',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [singleDoc, singlePreviewNavigator.currentUrl, singlePreviewNavigator.currentMetadata]);
|
||||||
|
|
||||||
|
const stackPreviews = useMemo(() => {
|
||||||
|
if (!stackDocuments.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return stackDocuments
|
||||||
|
.map((doc) => {
|
||||||
|
if (!doc) return null;
|
||||||
|
if (stackTopDocument && doc.id === stackTopDocument.id) {
|
||||||
|
const url = stackPreviewNavigator.currentUrl;
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const orientation = derivePreviewOrientation(stackPreviewNavigator.currentMetadata);
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
url,
|
||||||
|
orientation,
|
||||||
|
alt: doc.title || doc.original_name || 'Document preview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return makePreviewItem(doc, 1);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [
|
||||||
|
stackDocuments,
|
||||||
|
stackTopDocument,
|
||||||
|
stackPreviewNavigator.currentUrl,
|
||||||
|
stackPreviewNavigator.currentMetadata,
|
||||||
|
makePreviewItem,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const singleCardinality = singlePreviewNavigator.cardinality;
|
||||||
|
const singleEffectiveCardinality = singleCardinality || (singlePreviewNavigator.currentUrl ? 1 : 0);
|
||||||
|
const singleHasPreview = Boolean(singlePreviewNavigator.currentUrl);
|
||||||
|
|
||||||
|
const topDocId = stackTopDocument?.id || null;
|
||||||
|
const topCardinality = stackPreviewNavigator.cardinality;
|
||||||
|
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
|
||||||
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||||
|
const topOrdinal = stackPreviewNavigator.ordinal;
|
||||||
|
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
||||||
|
const topCanGoNext = stackPreviewNavigator.canGoNext;
|
||||||
|
|
||||||
const bulkTagUnion = useMemo(() => {
|
const bulkTagUnion = useMemo(() => {
|
||||||
if (!selectedDocuments.length) return [];
|
if (!selectedDocuments.length) return [];
|
||||||
@@ -649,11 +715,27 @@ const DetailPanel = ({
|
|||||||
? new Date(singleDoc.issued_at).toLocaleString()
|
? new Date(singleDoc.issued_at).toLocaleString()
|
||||||
: '—';
|
: '—';
|
||||||
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
|
||||||
|
const pageCountRaw = singleDoc.current_version?.metadata?.page_count;
|
||||||
|
const pageCountValue =
|
||||||
|
typeof pageCountRaw === 'number'
|
||||||
|
? pageCountRaw
|
||||||
|
: pageCountRaw != null && pageCountRaw !== ''
|
||||||
|
? Number.parseInt(pageCountRaw, 10)
|
||||||
|
: null;
|
||||||
|
const hasPageCount = Number.isFinite(pageCountValue) && pageCountValue >= 0;
|
||||||
const metadata =
|
const metadata =
|
||||||
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
|
||||||
|
const effectiveCardinality = singleEffectiveCardinality;
|
||||||
|
const canGoPrev = singlePreviewNavigator.canGoPrev;
|
||||||
|
const canGoNext = singlePreviewNavigator.canGoNext;
|
||||||
|
const hasPreviewImage = singleHasPreview;
|
||||||
|
const interceptNavPointer = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
|
||||||
<div className="preview-pane preview-pane--stack">
|
<div className="preview-pane preview-pane--stack">
|
||||||
<PreviewStack
|
<PreviewStack
|
||||||
items={singlePreviewItems}
|
items={singlePreviewItems}
|
||||||
@@ -663,7 +745,44 @@ const DetailPanel = ({
|
|||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
activeItemId={activePreviewId}
|
activeItemId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
|
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||||
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
singlePreviewNavigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptNavPointer}
|
||||||
|
onPointerUp={interceptNavPointer}
|
||||||
|
onMouseDown={interceptNavPointer}
|
||||||
|
onMouseUp={interceptNavPointer}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
singlePreviewNavigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptNavPointer}
|
||||||
|
onPointerUp={interceptNavPointer}
|
||||||
|
onMouseDown={interceptNavPointer}
|
||||||
|
onMouseUp={interceptNavPointer}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="doc-title-row">
|
<div className="doc-title-row">
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
@@ -728,6 +847,11 @@ const DetailPanel = ({
|
|||||||
<div>
|
<div>
|
||||||
<strong>Issued:</strong> {issuedAt}
|
<strong>Issued:</strong> {issuedAt}
|
||||||
</div>
|
</div>
|
||||||
|
{hasPageCount ? (
|
||||||
|
<div>
|
||||||
|
<strong>Pages:</strong> {pageCountValue}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
<strong>Original filename:</strong>{' '}
|
<strong>Original filename:</strong>{' '}
|
||||||
{singleDoc.original_name}
|
{singleDoc.original_name}
|
||||||
@@ -858,10 +982,20 @@ const DetailPanel = ({
|
|||||||
const renderBulk = () => {
|
const renderBulk = () => {
|
||||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||||
|
const topDoc = stackTopDocument;
|
||||||
|
const topDocIdLocal = topDocId;
|
||||||
|
const topOrdinal = stackPreviewNavigator.ordinal;
|
||||||
|
const topCardinalityLocal = topEffectiveCardinality;
|
||||||
|
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||||
|
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
||||||
|
const topCanGoNext = stackPreviewNavigator.canGoNext;
|
||||||
|
const interceptTopNavPointer = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
|
||||||
<div className="preview-pane preview-pane--stack">
|
<div className="preview-pane preview-pane--stack">
|
||||||
<PreviewStack
|
<PreviewStack
|
||||||
items={stackPreviews}
|
items={stackPreviews}
|
||||||
@@ -870,7 +1004,44 @@ const DetailPanel = ({
|
|||||||
onOpenPreview={onOpenPreview}
|
onOpenPreview={onOpenPreview}
|
||||||
activeItemId={activePreviewId}
|
activeItemId={activePreviewId}
|
||||||
/>
|
/>
|
||||||
|
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
||||||
|
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--prev"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
stackPreviewNavigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptTopNavPointer}
|
||||||
|
onPointerUp={interceptTopNavPointer}
|
||||||
|
onMouseDown={interceptTopNavPointer}
|
||||||
|
onMouseUp={interceptTopNavPointer}
|
||||||
|
disabled={!topCanGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="preview-pane__nav-button preview-pane__nav-button--next"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
stackPreviewNavigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={interceptTopNavPointer}
|
||||||
|
onPointerUp={interceptTopNavPointer}
|
||||||
|
onMouseDown={interceptTopNavPointer}
|
||||||
|
onMouseUp={interceptTopNavPointer}
|
||||||
|
disabled={!topCanGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
<h3 style={{ margin: 0 }}>{countLabel}</h3>
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
|
|||||||
@@ -1,13 +1,50 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { resolveDocumentAssetUrl } from '../asset_manager';
|
import { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
|
||||||
import { getTagColorStyle } from '../utils/colors';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon } from '../ui/icons';
|
import { DownloadIcon, EditIcon, ViewListIcon, ViewGridIcon, FolderIcon } from '../ui/icons';
|
||||||
|
|
||||||
const FOLDER_ICON_SRC = '/folder.svg';
|
|
||||||
|
|
||||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||||
|
const DEFAULT_GRID_ICON_SIZE = 96;
|
||||||
|
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||||
|
const LIST_ICON_SIZE = 48;
|
||||||
|
|
||||||
const 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 thumbnailView = useMemo(() => createAssetView(thumbnailAsset), [thumbnailAsset]);
|
||||||
|
const primaryMetadata = thumbnailView.getPrimaryMetadata() || {};
|
||||||
|
const assetWidth = Number(primaryMetadata?.width);
|
||||||
|
const assetHeight = Number(primaryMetadata?.height);
|
||||||
|
|
||||||
|
const dimensions = useMemo(() => {
|
||||||
|
if (!Number.isFinite(assetWidth) || assetWidth <= 0 || !Number.isFinite(assetHeight) || assetHeight <= 0) {
|
||||||
|
return { width: resolvedMaxSize, height: resolvedMaxSize };
|
||||||
|
}
|
||||||
|
const scale = Math.min(1, resolvedMaxSize / assetWidth, resolvedMaxSize / assetHeight);
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(assetWidth * scale)),
|
||||||
|
height: Math.max(1, Math.round(assetHeight * scale)),
|
||||||
|
};
|
||||||
|
}, [assetWidth, assetHeight, resolvedMaxSize]);
|
||||||
|
|
||||||
|
const innerStyle = useMemo(
|
||||||
|
() => ({ width: `${dimensions.width}px`, height: `${dimensions.height}px` }),
|
||||||
|
[dimensions.height, dimensions.width],
|
||||||
|
);
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() =>
|
() =>
|
||||||
resolveDocumentAssetUrl(document, 'thumbnail', {
|
resolveDocumentAssetUrl(document, 'thumbnail', {
|
||||||
@@ -17,8 +54,16 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
|
|||||||
[document, ensureAssetUrl, getDocumentAsset],
|
[document, ensureAssetUrl, getDocumentAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pageCount = getPageCount(document);
|
||||||
|
const showMultiPageBadge = Number.isFinite(pageCount) && pageCount > 1;
|
||||||
|
const innerClasses = ['document-thumbnail-inner'];
|
||||||
|
if (showMultiPageBadge) {
|
||||||
|
innerClasses.push('document-thumbnail-inner--multipage');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="document-thumbnail-wrapper">
|
<div className="document-thumbnail-wrapper">
|
||||||
|
<div className={innerClasses.join(' ')} style={innerStyle}>
|
||||||
{url ? (
|
{url ? (
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
@@ -31,6 +76,7 @@ const DocumentThumbnailImage = ({ document, ensureAssetUrl, getDocumentAsset, al
|
|||||||
<div className="thumb-placeholder">DOC</div>
|
<div className="thumb-placeholder">DOC</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,6 +143,7 @@ const DocumentsTable = ({
|
|||||||
);
|
);
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
|
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
|
||||||
const handleSetViewMode = useCallback(
|
const handleSetViewMode = useCallback(
|
||||||
(nextMode) => {
|
(nextMode) => {
|
||||||
if (!onViewModeChange) {
|
if (!onViewModeChange) {
|
||||||
@@ -226,6 +273,13 @@ const DocumentsTable = ({
|
|||||||
[onClearSelection],
|
[onClearSelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showDefaultEmptyState = !showingSearchResults && !subfolders.length && rows.length === 0;
|
||||||
|
const showListSearchEmptyState =
|
||||||
|
showingSearchResults && rows.length === 0 && !isGridView && !isSearchLoading;
|
||||||
|
const showGridSearchEmptyState =
|
||||||
|
isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading;
|
||||||
|
const showSearchHint = showingSearchResults && rows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
className={`documents-panel column documents-panel--view-${isGridView ? 'grid' : 'list'}`}
|
||||||
@@ -295,6 +349,16 @@ const DocumentsTable = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showDefaultEmptyState && (
|
||||||
|
<div className="empty-state">
|
||||||
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showGridSearchEmptyState && (
|
||||||
|
<div className="empty-state empty-state--global">
|
||||||
|
No documents match the current filters.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="column-body">
|
<div className="column-body">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -315,15 +379,12 @@ const DocumentsTable = ({
|
|||||||
}}
|
}}
|
||||||
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
aria-activedescendant={isGridView ? undefined : activeDescendantId}
|
||||||
>
|
>
|
||||||
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
{showDefaultEmptyState ? null : isGridView ? (
|
||||||
<div className="empty-state">
|
|
||||||
Drop files anywhere or onto a folder to upload documents.
|
|
||||||
</div>
|
|
||||||
) : isGridView ? (
|
|
||||||
<div
|
<div
|
||||||
className="documents-grid"
|
className="documents-grid"
|
||||||
role="list"
|
role="list"
|
||||||
onClick={handleGridBackgroundClick}
|
onClick={handleGridBackgroundClick}
|
||||||
|
style={{ '--documents-grid-icon-size': `${gridIconSize}px` }}
|
||||||
>
|
>
|
||||||
{!showingSearchResults &&
|
{!showingSearchResults &&
|
||||||
subfolders.map((folder) => {
|
subfolders.map((folder) => {
|
||||||
@@ -370,10 +431,9 @@ const DocumentsTable = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="folder-card__icon">
|
<div className="folder-card__icon">
|
||||||
<img
|
<FolderIcon
|
||||||
src={FOLDER_ICON_SRC}
|
|
||||||
alt="Folder"
|
|
||||||
className="folder-card__icon-svg"
|
className="folder-card__icon-svg"
|
||||||
|
size={gridIconSize}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="folder-card__meta">
|
<div className="folder-card__meta">
|
||||||
@@ -399,6 +459,7 @@ const DocumentsTable = ({
|
|||||||
className={cardClasses.join(' ')}
|
className={cardClasses.join(' ')}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
id={`document-card-${doc.id}`}
|
id={`document-card-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
@@ -416,6 +477,7 @@ const DocumentsTable = ({
|
|||||||
ensureAssetUrl={ensureAssetUrl}
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
getDocumentAsset={getDocumentAsset}
|
getDocumentAsset={getDocumentAsset}
|
||||||
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
alt={`Thumbnail for ${doc.title || doc.original_name}`}
|
||||||
|
maxSize={gridIconSize}
|
||||||
/>
|
/>
|
||||||
<div className="document-card__meta">
|
<div className="document-card__meta">
|
||||||
<div
|
<div
|
||||||
@@ -550,10 +612,9 @@ const DocumentsTable = ({
|
|||||||
>
|
>
|
||||||
<td className="thumb-cell">
|
<td className="thumb-cell">
|
||||||
<div className="thumb-icon">
|
<div className="thumb-icon">
|
||||||
<img
|
<FolderIcon
|
||||||
src={FOLDER_ICON_SRC}
|
|
||||||
alt="Folder"
|
|
||||||
className="thumb-icon__image"
|
className="thumb-icon__image"
|
||||||
|
size={32}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -615,6 +676,7 @@ const DocumentsTable = ({
|
|||||||
key={doc.id}
|
key={doc.id}
|
||||||
className={rowClasses.join(' ')}
|
className={rowClasses.join(' ')}
|
||||||
id={`document-row-${doc.id}`}
|
id={`document-row-${doc.id}`}
|
||||||
|
data-doc-id={doc.id}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
||||||
onDoubleClick={() => onDocumentOpen(doc.id)}
|
onDoubleClick={() => onDocumentOpen(doc.id)}
|
||||||
draggable
|
draggable
|
||||||
@@ -764,20 +826,15 @@ const DocumentsTable = ({
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 && showingSearchResults && !isGridView && !isSearchLoading && (
|
{showListSearchEmptyState && (
|
||||||
<div className="empty-state">No documents match the current filters.</div>
|
<div className="empty-state">No documents match the current filters.</div>
|
||||||
)}
|
)}
|
||||||
{showingSearchResults && rows.length > 0 && (
|
{showSearchHint && (
|
||||||
<div className="search-hint">
|
<div className="search-hint">
|
||||||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isGridView && showingSearchResults && rows.length === 0 && !isSearchLoading && (
|
|
||||||
<div className="empty-state empty-state--global">
|
|
||||||
No documents match the current filters.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createAssetView } from '../asset_manager';
|
||||||
|
|
||||||
|
const clampOrdinalValue = (value, cardinality, defaultOrdinal) => {
|
||||||
|
const raw = Number.isFinite(value) ? value : defaultOrdinal;
|
||||||
|
let next = Math.max(1, Math.floor(raw));
|
||||||
|
if (cardinality && cardinality > 0) {
|
||||||
|
next = Math.min(next, cardinality);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAssetNavigator = ({
|
||||||
|
document,
|
||||||
|
assetType,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset,
|
||||||
|
prefetch = 2,
|
||||||
|
defaultOrdinal = 1,
|
||||||
|
}) => {
|
||||||
|
const documentId = document?.id || null;
|
||||||
|
|
||||||
|
const asset = useMemo(() => {
|
||||||
|
if (!document || typeof getAsset !== 'function') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getAsset(document, assetType);
|
||||||
|
}, [document, assetType, getAsset]);
|
||||||
|
|
||||||
|
const view = useMemo(() => createAssetView(asset), [asset]);
|
||||||
|
const cardinality = view.getCardinality();
|
||||||
|
|
||||||
|
const [ordinal, setOrdinalInternal] = useState(defaultOrdinal);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOrdinalInternal(defaultOrdinal);
|
||||||
|
}, [documentId, assetType, defaultOrdinal]);
|
||||||
|
|
||||||
|
const setOrdinal = useCallback(
|
||||||
|
(next) => {
|
||||||
|
setOrdinalInternal((prev) => {
|
||||||
|
const target = typeof next === 'function' ? next(prev) : next;
|
||||||
|
return clampOrdinalValue(target, cardinality, defaultOrdinal);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[cardinality, defaultOrdinal],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goPrev = useCallback(() => setOrdinal((value) => value - 1), [setOrdinal]);
|
||||||
|
const goNext = useCallback(() => setOrdinal((value) => value + 1), [setOrdinal]);
|
||||||
|
|
||||||
|
const objects = view.getObjects();
|
||||||
|
const currentObject = view.getObject(ordinal);
|
||||||
|
const currentUrl = currentObject?.url || view.getPrimaryUrl();
|
||||||
|
const currentMetadata = currentObject?.metadata || view.getPrimaryMetadata() || null;
|
||||||
|
|
||||||
|
const canGoPrev = ordinal > 1;
|
||||||
|
const canGoNext = cardinality ? ordinal < cardinality : true;
|
||||||
|
|
||||||
|
const ordinalsNeedingLoad = useMemo(() => {
|
||||||
|
const missing = [];
|
||||||
|
if (!asset) {
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
const maxOrdinal = cardinality && cardinality > 0
|
||||||
|
? Math.min(cardinality, ordinal + Math.max(1, prefetch) - 1)
|
||||||
|
: ordinal + Math.max(1, prefetch) - 1;
|
||||||
|
|
||||||
|
for (let ord = ordinal; ord <= maxOrdinal; ord += 1) {
|
||||||
|
const object = view.getObject(ord);
|
||||||
|
if (!object?.url) {
|
||||||
|
missing.push(ord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing;
|
||||||
|
}, [asset, view, ordinal, prefetch, cardinality]);
|
||||||
|
|
||||||
|
const fetchStart = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[0] : null;
|
||||||
|
const fetchEnd = ordinalsNeedingLoad.length ? ordinalsNeedingLoad[ordinalsNeedingLoad.length - 1] : null;
|
||||||
|
const fetchLimit = fetchStart && fetchEnd ? fetchEnd - fetchStart + 1 : null;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentId || !asset || !ensureAssetUrl) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!fetchStart || !fetchLimit) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
ensureAssetUrl(documentId, asset, {
|
||||||
|
start: fetchStart,
|
||||||
|
limit: fetchLimit,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [documentId, asset, ensureAssetUrl, fetchStart, fetchLimit]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
documentId,
|
||||||
|
asset,
|
||||||
|
assetType,
|
||||||
|
ordinal,
|
||||||
|
setOrdinal,
|
||||||
|
goPrev,
|
||||||
|
goNext,
|
||||||
|
canGoPrev,
|
||||||
|
canGoNext,
|
||||||
|
cardinality,
|
||||||
|
currentObject,
|
||||||
|
currentUrl,
|
||||||
|
currentMetadata,
|
||||||
|
objects,
|
||||||
|
isLoading: loading,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAssetNavigator;
|
||||||
+141
-268
@@ -1,10 +1,10 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useContext,
|
|
||||||
useReducer,
|
useReducer,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
@@ -21,18 +21,18 @@ import {
|
|||||||
matchPath,
|
matchPath,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager';
|
import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||||
import useApiError from './hooks/useApiError';
|
import useApiError from './hooks/useApiError';
|
||||||
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
import SkeuomorphicWorkspace from './skeuomorphic_ws';
|
||||||
import DetailPanel from './detail/DetailPanel';
|
import DetailPanel from './detail/DetailPanel';
|
||||||
import TagsPanel from './tags/TagsPanel';
|
import TagsPanel from './tags/TagsPanel';
|
||||||
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
import CorrespondentsPanel from './correspondents/CorrespondentsPanel';
|
||||||
import { CORRESPONDENT_ROLES } from './constants/correspondents';
|
import { CORRESPONDENT_ROLES } from './constants/correspondents';
|
||||||
import { DownloadIcon } from './ui/icons';
|
|
||||||
import TagManager from './tag_manager';
|
import TagManager from './tag_manager';
|
||||||
import { formatFileSize } from './utils/format';
|
|
||||||
import Sidebar from './sidebar/Sidebar';
|
import Sidebar from './sidebar/Sidebar';
|
||||||
import DocumentsTable from './documents/DocumentsTable';
|
import DocumentsTable from './documents/DocumentsTable';
|
||||||
|
import { AppShellContext, useAppShell } from './appShellContext';
|
||||||
|
import DocumentViewerRoute from './routes/DocumentViewerRoute';
|
||||||
|
|
||||||
const runtimeApiBase =
|
const runtimeApiBase =
|
||||||
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
|
typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL
|
||||||
@@ -226,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 }) => (
|
const DocumentsLayout = ({ sidebarProps, children }) => (
|
||||||
<main className="documents-main">
|
<main className="documents-main">
|
||||||
<Sidebar {...sidebarProps} />
|
<Sidebar {...sidebarProps} />
|
||||||
@@ -318,19 +233,16 @@ const DocumentsLayout = ({ sidebarProps, children }) => (
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
||||||
const AppShellContext = React.createContext(null);
|
|
||||||
|
|
||||||
const AppLayout = () => {
|
const AppLayout = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const appState = useAppState();
|
const appState = useAppState();
|
||||||
const appDispatch = useAppDispatch();
|
const appDispatch = useAppDispatch();
|
||||||
const folderMatch = matchPath('/documents/folder/:folderId', location.pathname);
|
const folderMatch = matchPath('/documents/folder/:folderId', location.pathname);
|
||||||
const nestedDocMatch = matchPath('/documents/folder/:folderId/documents/:documentId', location.pathname);
|
const docMatch = matchPath('/documents/:documentId', location.pathname);
|
||||||
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
|
const routeFolderId = folderMatch?.params?.folderId || null;
|
||||||
const routeFolderId =
|
|
||||||
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
|
|
||||||
const routeDocumentId = docMatch?.params?.documentId || null;
|
const routeDocumentId = docMatch?.params?.documentId || null;
|
||||||
|
const previewDocumentId = routeDocumentId;
|
||||||
const { status: appStatus, token } = appState;
|
const { status: appStatus, token } = appState;
|
||||||
const [status, setStatus] = useState(null);
|
const [status, setStatus] = useState(null);
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
@@ -475,8 +387,6 @@ const AppLayout = () => {
|
|||||||
folderName: DEFAULT_FOLDER_NAME,
|
folderName: DEFAULT_FOLDER_NAME,
|
||||||
});
|
});
|
||||||
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
const [activePreviewId, setActivePreviewId] = useState(routeDocumentId || null);
|
||||||
const [previewDocumentId, setPreviewDocumentId] = useState(null);
|
|
||||||
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
|
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const shellRef = useRef(null);
|
const shellRef = useRef(null);
|
||||||
const assetManagerRef = useRef(null);
|
const assetManagerRef = useRef(null);
|
||||||
@@ -503,14 +413,37 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
const isAssetEquivalent = (lhs, rhs) => {
|
const isAssetEquivalent = (lhs, rhs) => {
|
||||||
if (!lhs || !rhs) return false;
|
if (!lhs || !rhs) return false;
|
||||||
|
const lhsView = createAssetView(lhs);
|
||||||
|
const rhsView = createAssetView(rhs);
|
||||||
|
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
|
||||||
|
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
|
||||||
|
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
|
||||||
|
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
|
||||||
|
const lhsObjects = lhsView.getObjects();
|
||||||
|
const rhsObjects = rhsView.getObjects();
|
||||||
|
const objectsComparable = lhsObjects.length === rhsObjects.length
|
||||||
|
&& lhsObjects.every((entry, index) => {
|
||||||
|
const other = rhsObjects[index];
|
||||||
|
if (!other) return false;
|
||||||
|
if (entry.ordinal !== other.ordinal) return false;
|
||||||
|
if (entry.url && other.url && entry.url === other.url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!entry.url && !other.url) {
|
||||||
|
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
|
||||||
|
}
|
||||||
|
return entry.url === other.url;
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
lhs.id === rhs.id &&
|
lhs.id === rhs.id &&
|
||||||
lhs.url === rhs.url &&
|
lhs.url === rhs.url &&
|
||||||
lhs?.metadata?.width === rhs?.metadata?.width &&
|
lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width &&
|
||||||
lhs?.metadata?.height === rhs?.metadata?.height &&
|
lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height &&
|
||||||
lhs.mime_type === rhs.mime_type &&
|
lhs.mime_type === rhs.mime_type &&
|
||||||
lhs.asset_type === rhs.asset_type &&
|
lhs.asset_type === rhs.asset_type &&
|
||||||
lhs.created_at === rhs.created_at
|
lhs.created_at === rhs.created_at &&
|
||||||
|
lhsCardinality === rhsCardinality &&
|
||||||
|
objectsComparable
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -608,8 +541,6 @@ const AppLayout = () => {
|
|||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
|
||||||
setActivePreviewId(null);
|
setActivePreviewId(null);
|
||||||
setPreviewDocumentId(null);
|
|
||||||
setPreviewDocumentLoading(false);
|
|
||||||
assetManager.reset();
|
assetManager.reset();
|
||||||
setPreviewEntries(() => new Map());
|
setPreviewEntries(() => new Map());
|
||||||
previewInflightRef.current = new Map();
|
previewInflightRef.current = new Map();
|
||||||
@@ -754,9 +685,6 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDocumentIds.length) {
|
if (!selectedDocumentIds.length) {
|
||||||
if (activePreviewId !== null) {
|
|
||||||
setActivePreviewId(null);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedDocumentIds.includes(activePreviewId)) {
|
if (!selectedDocumentIds.includes(activePreviewId)) {
|
||||||
@@ -2447,13 +2375,17 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ensureAssetUrl = useCallback(
|
const ensureAssetUrl = useCallback(
|
||||||
async (documentId, asset, { force = false } = {}) => {
|
async (documentId, asset, { force = false, start = null, limit = null } = {}) => {
|
||||||
if (!documentId || !asset?.id) {
|
if (!documentId || !asset?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await assetManager.ensureAsset(documentId, asset, { force });
|
const entry = await assetManager.ensureAsset(documentId, asset, {
|
||||||
|
force,
|
||||||
|
start,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return null;
|
return null;
|
||||||
@@ -3084,63 +3016,75 @@ const AppLayout = () => {
|
|||||||
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
[token, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDocumentPreview = useCallback(
|
const ensurePreviewData = useCallback(
|
||||||
async (documentId, { replace = false, skipNavigate = false } = {}) => {
|
async (documentId) => {
|
||||||
if (!documentId) return;
|
if (!documentId) return null;
|
||||||
setPreviewDocumentId(documentId);
|
|
||||||
setPreviewDocumentLoading(true);
|
const findInCache = () => {
|
||||||
try {
|
|
||||||
const pool = searchResults ?? documents;
|
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) {
|
if (!doc) {
|
||||||
throw new Error('Document metadata unavailable.');
|
throw new Error('Document metadata unavailable.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = doc.current_version || null;
|
setDocuments((prev) => {
|
||||||
const previewAsset = getAssetFromVersion(currentVersion, 'preview');
|
if (prev.some((item) => item.id === doc.id)) {
|
||||||
const thumbnailAsset = getAssetFromVersion(currentVersion, 'thumbnail');
|
return prev;
|
||||||
|
|
||||||
const refreshAssetIfNeeded = async (asset) => {
|
|
||||||
if (!asset?.id) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const expiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
return [doc, ...prev];
|
||||||
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
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
await refreshAssetIfNeeded(previewAsset);
|
const targetFolder = doc?.folder_id || selectedFolder || 'root';
|
||||||
refreshAssetIfNeeded(thumbnailAsset);
|
if (targetFolder && targetFolder !== selectedFolder) {
|
||||||
|
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
|
||||||
|
doc = findInCache() || doc;
|
||||||
|
}
|
||||||
|
|
||||||
await ensurePreviewUrl(documentId, { force: false });
|
await ensurePreviewUrl(documentId, { force: false });
|
||||||
setActivePreviewId(documentId);
|
setActivePreviewId(documentId);
|
||||||
if (!skipNavigate && navigate) {
|
return doc;
|
||||||
navigate(`/documents/${documentId}`, { replace });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
notifyApiError(error, 'Failed to open document preview.');
|
|
||||||
setPreviewDocumentId(null);
|
|
||||||
} finally {
|
|
||||||
setPreviewDocumentLoading(false);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
documents,
|
|
||||||
searchResults,
|
searchResults,
|
||||||
|
documents,
|
||||||
|
api,
|
||||||
|
assetManager,
|
||||||
|
setDocuments,
|
||||||
|
selectedFolder,
|
||||||
|
loadFolder,
|
||||||
|
isFilterActive,
|
||||||
ensurePreviewUrl,
|
ensurePreviewUrl,
|
||||||
ensureAssetUrl,
|
setActivePreviewId,
|
||||||
navigate,
|
|
||||||
notifyApiError,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openDocumentPreview = useCallback(
|
||||||
|
(documentId, { replace = false } = {}) => {
|
||||||
|
if (!documentId) return;
|
||||||
|
navigate(`/documents/${documentId}`, { replace });
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeDocumentPreview = useCallback(
|
||||||
|
(folderId = null) => {
|
||||||
|
const targetId = folderId || selectedFolder || 'root';
|
||||||
|
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||||
|
navigate(path, { replace: false });
|
||||||
|
},
|
||||||
|
[navigate, selectedFolder],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDocumentListFocus = useCallback(() => {
|
const handleDocumentListFocus = useCallback(() => {
|
||||||
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
if (focusedRowKey && navigableRowKeys.includes(focusedRowKey)) {
|
||||||
return;
|
return;
|
||||||
@@ -3269,24 +3213,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(() => {
|
useEffect(() => {
|
||||||
if (!previewDocumentId) return;
|
if (!previewDocumentId) return;
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
@@ -3298,63 +3224,6 @@ const AppLayout = () => {
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [previewDocumentId, closeDocumentPreview]);
|
}, [previewDocumentId, closeDocumentPreview]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!routeDocumentId) {
|
|
||||||
if (previewDocumentId) {
|
|
||||||
closeDocumentPreview();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const hydratePreview = async () => {
|
|
||||||
try {
|
|
||||||
const pool = searchResults ?? documents;
|
|
||||||
let doc = pool.find((item) => item.id === routeDocumentId) || null;
|
|
||||||
|
|
||||||
if (!doc) {
|
|
||||||
const { data } = await api.get(`/documents/${routeDocumentId}`);
|
|
||||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
|
||||||
const fetched = hydratedDetail?.document || data.document || data;
|
|
||||||
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetFolder = doc?.folder_id || routeFolderId || 'root';
|
|
||||||
if (targetFolder && targetFolder !== selectedFolder) {
|
|
||||||
await loadFolder(targetFolder, { showLoading: false, preserveSearch: isFilterActive });
|
|
||||||
}
|
|
||||||
if (!cancelled) {
|
|
||||||
await openDocumentPreview(routeDocumentId, { replace: true, skipNavigate: true });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!cancelled) {
|
|
||||||
notifyApiError(error, 'Failed to open document preview.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
hydratePreview();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
routeDocumentId,
|
|
||||||
routeFolderId,
|
|
||||||
api,
|
|
||||||
assetManager,
|
|
||||||
selectedFolder,
|
|
||||||
loadFolder,
|
|
||||||
openDocumentPreview,
|
|
||||||
previewDocumentId,
|
|
||||||
closeDocumentPreview,
|
|
||||||
documents,
|
|
||||||
searchResults,
|
|
||||||
notifyApiError,
|
|
||||||
isFilterActive,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleDocumentTitleUpdate = useCallback(
|
const handleDocumentTitleUpdate = useCallback(
|
||||||
async (documentId, nextTitle) => {
|
async (documentId, nextTitle) => {
|
||||||
const trimmed = nextTitle.trim();
|
const trimmed = nextTitle.trim();
|
||||||
@@ -3458,13 +3327,38 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagAttach = useCallback(
|
const handleDocumentTagAttach = useCallback(
|
||||||
async ({ documentId, tagId }) => {
|
async ({ documentId, tagId, tag: tagData = null }) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveTagForCache = () => {
|
||||||
|
const lookupTag = tagLookupById.get(tagId);
|
||||||
|
const source = lookupTag || tagData;
|
||||||
|
if (!source) {
|
||||||
|
return { id: tagId, label: 'Tag', color: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: source.id ?? tagId,
|
||||||
|
label: source.label || source.name || 'Tag',
|
||||||
|
color: Object.prototype.hasOwnProperty.call(source, 'color')
|
||||||
|
? source.color
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
await api.post(`/documents/${documentId}/tags`, { tag_ids: [tagId] });
|
||||||
|
updateDocumentCaches(documentId, (doc) => {
|
||||||
|
if (!doc) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
|
if (currentTags.some((existing) => existing?.id === tagId)) {
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
return { ...doc, tags: [...currentTags, resolveTagForCache()] };
|
||||||
|
});
|
||||||
setStatusMessage('Tag assigned.', 'success');
|
setStatusMessage('Tag assigned.', 'success');
|
||||||
await refreshCurrentFolder();
|
await refreshCurrentFolder();
|
||||||
return true;
|
return true;
|
||||||
@@ -3474,7 +3368,14 @@ const AppLayout = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, refreshCurrentFolder, notifyApiError, setStatusMessage],
|
[
|
||||||
|
api,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
notifyApiError,
|
||||||
|
setStatusMessage,
|
||||||
|
updateDocumentCaches,
|
||||||
|
tagLookupById,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagDrop = useCallback(
|
const handleDocumentTagDrop = useCallback(
|
||||||
@@ -3487,7 +3388,7 @@ const AppLayout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id });
|
const attached = await handleDocumentTagAttach({ documentId, tagId: tag.id, tag });
|
||||||
if (!attached) {
|
if (!attached) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3952,14 +3853,14 @@ const AppLayout = () => {
|
|||||||
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
|
return TAG_MIME_TYPES.some((type) => Array.from(types).includes(type));
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDocumentRowTarget = (target) =>
|
const isDocumentDropTarget = (target) =>
|
||||||
target instanceof Element ? Boolean(target.closest('tr.document')) : false;
|
target instanceof Element ? Boolean(target.closest('[data-doc-id]')) : false;
|
||||||
|
|
||||||
const handleTagDragOver = (event) => {
|
const handleTagDragOver = (event) => {
|
||||||
if (!isTagTransfer(event)) {
|
if (!isTagTransfer(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isDocumentRowTarget(event.target)) {
|
if (isDocumentDropTarget(event.target)) {
|
||||||
setTagRemovalCursor(false);
|
setTagRemovalCursor(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3974,7 +3875,7 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
const related = event.relatedTarget;
|
const related = event.relatedTarget;
|
||||||
if (related instanceof Element && host.contains(related)) {
|
if (related instanceof Element && host.contains(related)) {
|
||||||
if (isDocumentRowTarget(related)) {
|
if (isDocumentDropTarget(related)) {
|
||||||
setTagRemovalCursor(false);
|
setTagRemovalCursor(false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -3987,7 +3888,7 @@ const AppLayout = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTagRemovalCursor(false);
|
setTagRemovalCursor(false);
|
||||||
if (isDocumentRowTarget(event.target) || event.defaultPrevented) {
|
if (isDocumentDropTarget(event.target) || event.defaultPrevented) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -4573,13 +4474,11 @@ const AppLayout = () => {
|
|||||||
onRefresh: refreshCurrentFolder,
|
onRefresh: refreshCurrentFolder,
|
||||||
onDocumentOpen: openDocumentPreview,
|
onDocumentOpen: openDocumentPreview,
|
||||||
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
|
||||||
availableTags: tags,
|
|
||||||
onCreateTag: handleTagCreate,
|
|
||||||
onAssignTagToDocument: handleDocumentTagAttach,
|
onAssignTagToDocument: handleDocumentTagAttach,
|
||||||
onRemoveTagFromDocument: handleTagRemove,
|
onRemoveTagFromDocument: handleTagRemove,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
prepareTagPayload: buildTagPayload,
|
activeTagIds: activeTagFilters,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
documents,
|
documents,
|
||||||
@@ -4590,13 +4489,11 @@ const AppLayout = () => {
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
openDocumentPreview,
|
openDocumentPreview,
|
||||||
resolveThumbnailUrlForDoc,
|
resolveThumbnailUrlForDoc,
|
||||||
tags,
|
|
||||||
handleTagCreate,
|
|
||||||
handleDocumentTagAttach,
|
handleDocumentTagAttach,
|
||||||
handleTagRemove,
|
handleTagRemove,
|
||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
buildTagPayload,
|
activeTagFilters,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4635,6 +4532,9 @@ const AppLayout = () => {
|
|||||||
showSkeuoWorkspace,
|
showSkeuoWorkspace,
|
||||||
exitSkeuoWorkspace,
|
exitSkeuoWorkspace,
|
||||||
skeuoWorkspaceProps,
|
skeuoWorkspaceProps,
|
||||||
|
ensurePreviewData,
|
||||||
|
notifyApiError,
|
||||||
|
resolveApiPath,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
token,
|
token,
|
||||||
@@ -4670,6 +4570,9 @@ const AppLayout = () => {
|
|||||||
showSkeuoWorkspace,
|
showSkeuoWorkspace,
|
||||||
exitSkeuoWorkspace,
|
exitSkeuoWorkspace,
|
||||||
skeuoWorkspaceProps,
|
skeuoWorkspaceProps,
|
||||||
|
ensurePreviewData,
|
||||||
|
notifyApiError,
|
||||||
|
resolveApiPath,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4826,46 +4729,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 DocumentsRoute = () => {
|
||||||
const {
|
const {
|
||||||
sidebarProps,
|
sidebarProps,
|
||||||
previewActive,
|
|
||||||
previewWorkspaceDocument,
|
|
||||||
previewWorkspaceEntry,
|
|
||||||
closeDocumentPreview,
|
|
||||||
handleThumbnailRegeneration,
|
|
||||||
documentsTableProps,
|
documentsTableProps,
|
||||||
detailPanelProps,
|
detailPanelProps,
|
||||||
workspaceMode,
|
workspaceMode,
|
||||||
skeuoWorkspaceProps,
|
skeuoWorkspaceProps,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
|
|
||||||
if (previewActive && previewWorkspaceDocument) {
|
|
||||||
return (
|
|
||||||
<main className="preview-main">
|
|
||||||
<PreviewWorkspace
|
|
||||||
document={previewWorkspaceDocument}
|
|
||||||
previewEntry={previewWorkspaceEntry}
|
|
||||||
onClose={closeDocumentPreview}
|
|
||||||
onRegenerateThumbnails={handleThumbnailRegeneration}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspaceMode === 'skeuo') {
|
if (workspaceMode === 'skeuo') {
|
||||||
return (
|
return (
|
||||||
<main className="skeuo-main">
|
<DocumentsLayout sidebarProps={sidebarProps}>
|
||||||
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
<SkeuomorphicWorkspace {...skeuoWorkspaceProps} />
|
||||||
</main>
|
</DocumentsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4998,11 +4875,7 @@ const AppRouter = () => (
|
|||||||
<Route path="/" element={<Navigate to="/documents" replace />} />
|
<Route path="/" element={<Navigate to="/documents" replace />} />
|
||||||
<Route path="/documents" element={<DocumentsRoute />} />
|
<Route path="/documents" element={<DocumentsRoute />} />
|
||||||
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
|
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
|
||||||
<Route
|
<Route path="/documents/:documentId" element={<DocumentViewerRoute />} />
|
||||||
path="/documents/folder/:folderId/documents/:documentId"
|
|
||||||
element={<DocumentsRoute />}
|
|
||||||
/>
|
|
||||||
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
|
|
||||||
<Route path="/tags" element={<TagsRoute />} />
|
<Route path="/tags" element={<TagsRoute />} />
|
||||||
<Route path="/correspondents" element={<CorrespondentsRoute />} />
|
<Route path="/correspondents" element={<CorrespondentsRoute />} />
|
||||||
<Route path="*" element={<Navigate to="/documents" replace />} />
|
<Route path="*" element={<Navigate to="/documents" replace />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
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';
|
import { getTagColorStyle } from '../utils/colors';
|
||||||
|
|
||||||
const FolderNode = ({
|
const FolderNode = ({
|
||||||
@@ -68,7 +67,7 @@ const FolderNode = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="name">
|
<span className="name">
|
||||||
<img src={FOLDER_ICON_SRC} alt="Folder" className="folder-icon-image" />
|
<FolderIcon className="folder-icon-image" size={16} />
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
{node.id !== 'root' && (
|
{node.id !== 'root' && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
grid-column: 2 / -1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--surface-subtle);
|
background-color: var(--surface-subtle);
|
||||||
@@ -158,7 +159,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 0.28rem 0.7rem;
|
padding: 0.28rem 0.7rem;
|
||||||
border-radius: 1px;
|
border-radius: 1rem;
|
||||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -188,72 +189,63 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeuo-tag-shelf {
|
.skeuo-card__nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2.25rem;
|
bottom: calc(3em * 0.707 * var(--nav-scale));
|
||||||
right: 1rem;
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(calc(0.707 * var(--nav-scale, 1)));
|
||||||
|
transform-origin: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 2rem;
|
||||||
gap: 0.6rem;
|
opacity: 0;
|
||||||
align-items: flex-end;
|
pointer-events: none;
|
||||||
max-height: calc(100% - 4rem);
|
transition: opacity 0.2s ease;
|
||||||
overflow: auto;
|
|
||||||
padding: 0;
|
|
||||||
z-index: 100000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag-shelf .skeuo-tag {
|
.skeuo-item__card:hover .skeuo-card__nav {
|
||||||
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;
|
opacity: 1;
|
||||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag-add {
|
.skeuo-card__nav-button {
|
||||||
width: 2.4rem;
|
display: inline-flex;
|
||||||
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 3.3em;
|
||||||
|
height: 3.3em;
|
||||||
|
padding: 0.36em;
|
||||||
|
border-radius: 3.3em;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-top: 0.4rem;
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
transition: background 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag-add:hover {
|
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||||
background: rgba(255, 255, 255, 1);
|
background: rgba(0, 0, 0, 0.75);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag-add:active {
|
.skeuo-card__nav-button:disabled {
|
||||||
transform: translateY(0);
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-tag-add:focus-visible {
|
.skeuo-card__nav-button:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent, #2684ff);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeuo-card__nav-button svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
body.skeuo-cursor-remove,
|
body.skeuo-cursor-remove,
|
||||||
body.skeuo-cursor-remove * {
|
body.skeuo-cursor-remove * {
|
||||||
cursor: not-allowed !important;
|
cursor: not-allowed !important;
|
||||||
@@ -271,6 +263,7 @@ body.skeuo-cursor-remove * {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.24);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
--nav-scale: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-item__card--empty {
|
.skeuo-item__card--empty {
|
||||||
|
|||||||
+151
-215
@@ -6,8 +6,10 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { resolveDocumentAssetUrl } from './asset_manager';
|
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
|
||||||
import { generateRandomTagColor, getReadableTextColor } from './utils/colors';
|
import { useAssetNavigator } from './hooks/useAssetNavigator';
|
||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
|
||||||
|
import { getReadableTextColor } from './utils/colors';
|
||||||
import './skeuomorphic_ws.css';
|
import './skeuomorphic_ws.css';
|
||||||
|
|
||||||
const ITEM_WIDTH = 220;
|
const ITEM_WIDTH = 220;
|
||||||
@@ -31,7 +33,6 @@ const ZOOM_MIN_SCALE = 1.05;
|
|||||||
const ZOOM_MAX_SCALE = 5;
|
const ZOOM_MAX_SCALE = 5;
|
||||||
const TAG_REMOVE_DISTANCE = 160;
|
const TAG_REMOVE_DISTANCE = 160;
|
||||||
|
|
||||||
const DRAG_PREVIEW_KEY = Symbol('dragPreview');
|
|
||||||
const DEBUG_DRAG = false;
|
const DEBUG_DRAG = false;
|
||||||
const DEBUG_FOCUS = true;
|
const DEBUG_FOCUS = true;
|
||||||
const DEBUG_DROP = true;
|
const DEBUG_DROP = true;
|
||||||
@@ -44,6 +45,107 @@ const resolveTagKey = (tag) => {
|
|||||||
return key != null ? String(key) : null;
|
return key != null ? String(key) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SkeuoPreviewCard = ({
|
||||||
|
doc,
|
||||||
|
title,
|
||||||
|
ensureAssetUrl,
|
||||||
|
getDocumentAsset,
|
||||||
|
navScale = 1,
|
||||||
|
prefetch = 3,
|
||||||
|
}) => {
|
||||||
|
const navigator = useAssetNavigator({
|
||||||
|
document: doc,
|
||||||
|
assetType: 'preview',
|
||||||
|
ensureAssetUrl,
|
||||||
|
getAsset: getDocumentAsset,
|
||||||
|
prefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { currentUrl, cardinality, canGoPrev, canGoNext } = navigator;
|
||||||
|
const hasPreview = Boolean(currentUrl);
|
||||||
|
const cardClasses = ['skeuo-item__card'];
|
||||||
|
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
||||||
|
const showNav = hasPreview && (cardinality > 1 || canGoPrev || canGoNext);
|
||||||
|
const navStyle = useMemo(() => ({ '--nav-scale': navScale }), [navScale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cardClasses.join(' ')}>
|
||||||
|
{hasPreview ? (
|
||||||
|
<img src={currentUrl} alt={title} />
|
||||||
|
) : (
|
||||||
|
<div className="skeuo-item__empty">
|
||||||
|
<div className="skeuo-item__placeholder">DOC</div>
|
||||||
|
<div className="skeuo-item__title" title={title}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showNav ? (
|
||||||
|
<div className="skeuo-card__nav" style={navStyle}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skeuo-card__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
navigator.goPrev();
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
disabled={!canGoPrev}
|
||||||
|
aria-label="Previous preview"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="skeuo-card__nav-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
navigator.goNext();
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseUp={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
disabled={!canGoNext}
|
||||||
|
aria-label="Next preview"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const generateInitialLayout = (
|
const generateInitialLayout = (
|
||||||
entries,
|
entries,
|
||||||
{
|
{
|
||||||
@@ -211,6 +313,15 @@ const useDocumentDrag = ({
|
|||||||
if (!state || state.pointerId !== pointerId) {
|
if (!state || state.pointerId !== pointerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const capturedTarget = state.capturedTarget;
|
||||||
|
if (capturedTarget && typeof capturedTarget.releasePointerCapture === 'function') {
|
||||||
|
try {
|
||||||
|
capturedTarget.releasePointerCapture(pointerId);
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}
|
||||||
dragStateRef.current = null;
|
dragStateRef.current = null;
|
||||||
setDraggingId((current) => (current === state.docId ? null : current));
|
setDraggingId((current) => (current === state.docId ? null : current));
|
||||||
syncLayoutSnapshot();
|
syncLayoutSnapshot();
|
||||||
@@ -247,6 +358,15 @@ const useDocumentDrag = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
bringToFront(docId);
|
bringToFront(docId);
|
||||||
|
const capturedTarget = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
|
||||||
|
if (capturedTarget && typeof capturedTarget.setPointerCapture === 'function') {
|
||||||
|
try {
|
||||||
|
capturedTarget.setPointerCapture(event.pointerId);
|
||||||
|
} catch (
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
error
|
||||||
|
) {}
|
||||||
|
}
|
||||||
dragStateRef.current = {
|
dragStateRef.current = {
|
||||||
docId,
|
docId,
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
@@ -260,6 +380,7 @@ const useDocumentDrag = ({
|
|||||||
width: docWidth,
|
width: docWidth,
|
||||||
height: docHeight,
|
height: docHeight,
|
||||||
scale: baseScale,
|
scale: baseScale,
|
||||||
|
capturedTarget,
|
||||||
};
|
};
|
||||||
setDraggingId(docId);
|
setDraggingId(docId);
|
||||||
},
|
},
|
||||||
@@ -432,20 +553,17 @@ const SkeuomorphicWorkspace = ({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onDocumentOpen,
|
onDocumentOpen,
|
||||||
resolveThumbnailUrl,
|
resolveThumbnailUrl,
|
||||||
availableTags = [],
|
|
||||||
onCreateTag = null,
|
|
||||||
onAssignTagToDocument = null,
|
onAssignTagToDocument = null,
|
||||||
onRemoveTagFromDocument = null,
|
onRemoveTagFromDocument = null,
|
||||||
ensureAssetUrl = null,
|
ensureAssetUrl = null,
|
||||||
getDocumentAsset = () => null,
|
getDocumentAsset = () => null,
|
||||||
prepareTagPayload = null,
|
activeTagIds = [],
|
||||||
}) => {
|
}) => {
|
||||||
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
|
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const tagShelfRef = useRef(null);
|
|
||||||
const layoutRef = useRef(new Map());
|
const layoutRef = useRef(new Map());
|
||||||
const itemRefs = useRef(new Map());
|
const itemRefs = useRef(new Map());
|
||||||
const zCounterRef = useRef(10);
|
const zCounterRef = useRef(10);
|
||||||
@@ -453,17 +571,27 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
const [layoutSnapshot, setLayoutSnapshot] = useState(() => new Map());
|
||||||
|
|
||||||
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
|
||||||
const [tagShelfWidth, setTagShelfWidth] = useState(0);
|
|
||||||
const [draggingId, setDraggingId] = useState(null);
|
const [draggingId, setDraggingId] = useState(null);
|
||||||
const [zoomedId, setZoomedId] = useState(null);
|
const [zoomedId, setZoomedId] = useState(null);
|
||||||
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
const [tagDropTargetId, setTagDropTargetId] = useState(null);
|
||||||
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
const [pendingTagDocId, setPendingTagDocId] = useState(null);
|
||||||
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
const [pendingRemovalTag, setPendingRemovalTag] = useState(null);
|
||||||
const [activeShelfTagId, setActiveShelfTagId] = useState(null);
|
|
||||||
const draggingTagRef = useRef(null);
|
const draggingTagRef = useRef(null);
|
||||||
const pendingDocTagDragRef = useRef(null);
|
const pendingDocTagDragRef = useRef(null);
|
||||||
const docSizeMapRef = useRef(new Map());
|
const docSizeMapRef = useRef(new Map());
|
||||||
const removalCursorActiveRef = useRef(false);
|
const removalCursorActiveRef = useRef(false);
|
||||||
|
const activeTagSet = useMemo(() => {
|
||||||
|
if (!Array.isArray(activeTagIds) || activeTagIds.length === 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
const set = new Set();
|
||||||
|
activeTagIds.forEach((id) => {
|
||||||
|
if (id != null) {
|
||||||
|
set.add(String(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return set;
|
||||||
|
}, [activeTagIds]);
|
||||||
|
|
||||||
const resolvePreviewAsset = useCallback(
|
const resolvePreviewAsset = useCallback(
|
||||||
(doc) => {
|
(doc) => {
|
||||||
@@ -477,8 +605,10 @@ const SkeuomorphicWorkspace = ({
|
|||||||
(doc) => {
|
(doc) => {
|
||||||
if (!doc) return null;
|
if (!doc) return null;
|
||||||
const asset = resolvePreviewAsset(doc);
|
const asset = resolvePreviewAsset(doc);
|
||||||
const width = asset?.metadata?.width;
|
const view = createAssetView(asset);
|
||||||
const height = asset?.metadata?.height;
|
const metadata = view.getPrimaryMetadata() || {};
|
||||||
|
const width = metadata?.width;
|
||||||
|
const height = metadata?.height;
|
||||||
if (typeof width === 'number' && typeof height === 'number') {
|
if (typeof width === 'number' && typeof height === 'number') {
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
@@ -487,17 +617,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
[resolvePreviewAsset],
|
[resolvePreviewAsset],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resolvePreviewUrl = useCallback(
|
|
||||||
(doc) => {
|
|
||||||
if (!doc) return null;
|
|
||||||
return resolveDocumentAssetUrl(doc, 'preview', {
|
|
||||||
ensureAssetUrl,
|
|
||||||
getAsset: getDocumentAsset,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[ensureAssetUrl, getDocumentAsset],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ensureAssetUrl) {
|
if (!ensureAssetUrl) {
|
||||||
return;
|
return;
|
||||||
@@ -591,72 +710,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(() => {
|
const handleTagDragEnd = useCallback(() => {
|
||||||
updateRemovalCursor(false);
|
updateRemovalCursor(false);
|
||||||
setTagDropTargetId(null);
|
setTagDropTargetId(null);
|
||||||
}, [updateRemovalCursor]);
|
}, [updateRemovalCursor]);
|
||||||
|
|
||||||
const handleShelfTagDragEndWithReset = useCallback((event) => {
|
|
||||||
if (event?.currentTarget instanceof HTMLElement) {
|
|
||||||
const target = event.currentTarget;
|
|
||||||
const showNode = () => {
|
|
||||||
if (target instanceof HTMLElement) {
|
|
||||||
target.classList.remove('is-drag-hidden');
|
|
||||||
const stored = target[DRAG_PREVIEW_KEY];
|
|
||||||
cleanupPreview(stored);
|
|
||||||
delete target[DRAG_PREVIEW_KEY];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
||||||
window.requestAnimationFrame(showNode);
|
|
||||||
} else {
|
|
||||||
setTimeout(showNode, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handleTagDragEnd();
|
|
||||||
queueFocusCanvas();
|
|
||||||
}, [handleTagDragEnd, queueFocusCanvas]);
|
|
||||||
|
|
||||||
const ensureDocumentSize = useCallback((doc) => {
|
const ensureDocumentSize = useCallback((doc) => {
|
||||||
const key = resolveSizeKey(doc);
|
const key = resolveSizeKey(doc);
|
||||||
const cache = docSizeMapRef.current.get(key);
|
const cache = docSizeMapRef.current.get(key);
|
||||||
@@ -721,17 +779,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
docSizeMapRef.current = new Map();
|
docSizeMapRef.current = new Map();
|
||||||
}, [items]);
|
}, [items]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeShelfTagId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const stillExists = availableTags.some((tag) => resolveTagKey(tag) === activeShelfTagId);
|
|
||||||
if (!stillExists) {
|
|
||||||
setActiveShelfTagId(null);
|
|
||||||
}
|
|
||||||
}, [activeShelfTagId, availableTags]);
|
|
||||||
|
|
||||||
|
|
||||||
const resolveZoomMetrics = useCallback(
|
const resolveZoomMetrics = useCallback(
|
||||||
(doc, cardWidth, cardHeight) => {
|
(doc, cardWidth, cardHeight) => {
|
||||||
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
const canvasWidth = canvasSize.width || DEFAULT_CANVAS_WIDTH;
|
||||||
@@ -821,30 +868,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
[resolvePreviewDimensions],
|
[resolvePreviewDimensions],
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const shelfNode = tagShelfRef.current;
|
|
||||||
if (!shelfNode || !availableTags.length) {
|
|
||||||
setTagShelfWidth(0);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const measure = () => {
|
|
||||||
const rect = shelfNode.getBoundingClientRect();
|
|
||||||
setTagShelfWidth(Math.ceil(rect.width));
|
|
||||||
};
|
|
||||||
|
|
||||||
measure();
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'undefined') {
|
|
||||||
window.addEventListener('resize', measure);
|
|
||||||
return () => window.removeEventListener('resize', measure);
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => measure());
|
|
||||||
observer.observe(shelfNode);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [availableTags.length]);
|
|
||||||
|
|
||||||
const syncLayoutSnapshot = useCallback(() => {
|
const syncLayoutSnapshot = useCallback(() => {
|
||||||
setLayoutSnapshot(new Map(layoutRef.current));
|
setLayoutSnapshot(new Map(layoutRef.current));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -950,7 +973,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
startZ: maxZ,
|
startZ: maxZ,
|
||||||
rotationRange: ROTATION_RANGE,
|
rotationRange: ROTATION_RANGE,
|
||||||
minSpacing: 48,
|
minSpacing: 48,
|
||||||
shelfWidth: tagShelfWidth > 0 ? tagShelfWidth + CANVAS_PADDING : 0,
|
shelfWidth: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
generatedLayout.forEach((entry, docId) => {
|
generatedLayout.forEach((entry, docId) => {
|
||||||
@@ -971,7 +994,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
canvasSize.height,
|
canvasSize.height,
|
||||||
ensureDocumentSize,
|
ensureDocumentSize,
|
||||||
syncLayoutSnapshot,
|
syncLayoutSnapshot,
|
||||||
tagShelfWidth,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1150,7 +1172,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
|
|
||||||
setPendingTagDocId(doc.id);
|
setPendingTagDocId(doc.id);
|
||||||
try {
|
try {
|
||||||
await onAssignTagToDocument({ documentId: doc.id, tagId });
|
await onAssignTagToDocument({ documentId: doc.id, tagId, tag: payload });
|
||||||
markActiveTagDropHandled(tagId, sourceDocId);
|
markActiveTagDropHandled(tagId, sourceDocId);
|
||||||
if (DEBUG_DROP) {
|
if (DEBUG_DROP) {
|
||||||
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
|
console.log('[skeuo] handleTagDropOnDoc: assigned tag', tagId, 'to doc', doc.id);
|
||||||
@@ -1504,7 +1526,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
<div
|
<div
|
||||||
className="skeuo-canvas"
|
className="skeuo-canvas"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
data-active-tag={activeShelfTagId || undefined}
|
|
||||||
onDragOver={handleCanvasDragOver}
|
onDragOver={handleCanvasDragOver}
|
||||||
onDragLeave={handleCanvasDragLeave}
|
onDragLeave={handleCanvasDragLeave}
|
||||||
onDrop={handleCanvasDrop}
|
onDrop={handleCanvasDrop}
|
||||||
@@ -1562,9 +1583,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
const totalScale = zoomScale > 0 ? zoomScale : 1;
|
||||||
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
const inverseTagScale = totalScale > 0 ? 1 / totalScale : 1;
|
||||||
const tagsStyle = { '--tag-scale': inverseTagScale };
|
const tagsStyle = { '--tag-scale': inverseTagScale };
|
||||||
const previewUrl = resolvePreviewUrl(doc);
|
|
||||||
const imageUrl = previewUrl;
|
|
||||||
const hasPreview = Boolean(imageUrl);
|
|
||||||
const title = doc.title || doc.original_name || 'Document';
|
const title = doc.title || doc.original_name || 'Document';
|
||||||
const dragging = draggingId === doc.id;
|
const dragging = draggingId === doc.id;
|
||||||
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
const tags = Array.isArray(doc.tags) ? doc.tags : [];
|
||||||
@@ -1572,7 +1590,7 @@ const SkeuomorphicWorkspace = ({
|
|||||||
.map((tag) => resolveTagKey(tag))
|
.map((tag) => resolveTagKey(tag))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const matchesFilter =
|
const matchesFilter =
|
||||||
!activeShelfTagId || docTagKeys.includes(activeShelfTagId);
|
activeTagSet.size === 0 || docTagKeys.some((key) => activeTagSet.has(key));
|
||||||
const dropActive = tagDropTargetId === doc.id;
|
const dropActive = tagDropTargetId === doc.id;
|
||||||
const dropPending = pendingTagDocId === doc.id;
|
const dropPending = pendingTagDocId === doc.id;
|
||||||
const itemClasses = ['skeuo-item'];
|
const itemClasses = ['skeuo-item'];
|
||||||
@@ -1581,8 +1599,6 @@ const SkeuomorphicWorkspace = ({
|
|||||||
if (dropActive) itemClasses.push('is-tag-target');
|
if (dropActive) itemClasses.push('is-tag-target');
|
||||||
if (dropPending) itemClasses.push('is-tag-pending');
|
if (dropPending) itemClasses.push('is-tag-pending');
|
||||||
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
if (!matchesFilter) itemClasses.push('is-filtered-out');
|
||||||
const cardClasses = ['skeuo-item__card'];
|
|
||||||
if (!hasPreview) cardClasses.push('skeuo-item__card--empty');
|
|
||||||
const docTagTokens = docTagKeys.join(' ');
|
const docTagTokens = docTagKeys.join(' ');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1618,18 +1634,13 @@ const SkeuomorphicWorkspace = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="skeuo-item__body" style={bodyStyle}>
|
<div className="skeuo-item__body" style={bodyStyle}>
|
||||||
<div className={cardClasses.join(' ')}>
|
<SkeuoPreviewCard
|
||||||
{hasPreview ? (
|
doc={doc}
|
||||||
<img src={imageUrl} alt={title} />
|
title={title}
|
||||||
) : (
|
ensureAssetUrl={ensureAssetUrl}
|
||||||
<div className="skeuo-item__empty">
|
getDocumentAsset={getDocumentAsset}
|
||||||
<div className="skeuo-item__placeholder">DOC</div>
|
navScale={inverseTagScale}
|
||||||
<div className="skeuo-item__title" title={title}>
|
/>
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
<div className="skeuo-item__tags" aria-hidden="true" style={tagsStyle}>
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
@@ -1674,81 +1685,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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+111
-29
@@ -36,6 +36,8 @@
|
|||||||
--sidebar-hover-bg: rgba(63, 106, 216, 0.08);
|
--sidebar-hover-bg: rgba(63, 106, 216, 0.08);
|
||||||
--sidebar-active-bg: rgba(63, 106, 216, 0.16);
|
--sidebar-active-bg: rgba(63, 106, 216, 0.16);
|
||||||
--selection-ring: #9bb6ff;
|
--selection-ring: #9bb6ff;
|
||||||
|
--documents-grid-title-size: 12px;
|
||||||
|
--documents-grid-icon-size: 128px;
|
||||||
|
|
||||||
/* Semantic colors */
|
/* Semantic colors */
|
||||||
--success: #2e7d6b;
|
--success: #2e7d6b;
|
||||||
@@ -391,7 +393,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
.documents-main {
|
.documents-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20rem minmax(0, 1fr) 20rem;
|
grid-template-columns: 20rem minmax(0, 1fr) 30rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -749,7 +751,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.column + .column {
|
.column + .column {
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,12 +859,6 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
padding-right: 3rem;
|
padding-right: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-row span.name .folder-icon-image {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-row:hover {
|
.folder-row:hover {
|
||||||
background: var(--sidebar-hover-bg);
|
background: var(--sidebar-hover-bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
@@ -945,6 +940,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
color: var(--sidebar-fg);
|
color: var(--sidebar-fg);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section:first-of-type,
|
.sidebar-section:first-of-type,
|
||||||
@@ -1182,34 +1178,49 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-thumbnail-wrapper {
|
.documents-panel--view-grid .document-thumbnail-wrapper {
|
||||||
width: 48px;
|
width: var(--documents-grid-icon-size);
|
||||||
height: 48px;
|
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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel--view-grid .document-thumbnail-wrapper {
|
.document-thumbnail-inner--multipage::after {
|
||||||
width: 128px;
|
content: '';
|
||||||
height: 128px;
|
position: absolute;
|
||||||
padding: 8px;
|
top: 0;
|
||||||
display: flex;
|
right: 0;
|
||||||
align-items: center;
|
width: 24px;
|
||||||
justify-content: center;
|
height: 24px;
|
||||||
border-radius: 10px;
|
background-image: url('./assets/papercorner.svg');
|
||||||
overflow: hidden;
|
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 {
|
.document-thumbnail {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
box-shadow: 0 1px 6px var(--shadow-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.documents-panel--view-grid .document-thumbnail {
|
.documents-panel--view-grid .document-thumbnail {
|
||||||
box-shadow: 0 1px 12px var(--shadow-medium);
|
box-shadow: 0 1px 12px var(--shadow-medium);
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-placeholder {
|
.thumb-placeholder {
|
||||||
@@ -1307,7 +1318,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
|
|
||||||
.documents-grid {
|
.documents-grid {
|
||||||
display: 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;
|
gap: 0.3rem 1.3rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1360,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
font-size: var(--documents-grid-title-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card.selected .document-card__title {
|
.document-card.selected .document-card__title {
|
||||||
@@ -1377,8 +1389,8 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 128px;
|
width: var(--documents-grid-icon-size);
|
||||||
height: 128px;
|
height: var(--documents-grid-icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-card__icon-svg {
|
.folder-card__icon-svg {
|
||||||
@@ -1399,11 +1411,11 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: break-word;
|
white-space: break-word;
|
||||||
|
font-size: var(--documents-grid-title-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.doc-name__title {
|
.doc-name__title {
|
||||||
font-weight: 600;
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -1497,6 +1509,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-panel .column-body {
|
.detail-panel .column-body {
|
||||||
@@ -1577,6 +1590,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail-preview {
|
.thumbnail-preview {
|
||||||
@@ -1659,7 +1673,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
|
|
||||||
.preview-stack--stacked {
|
.preview-stack--stacked {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 320px;
|
min-height: 420px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1715,11 +1729,75 @@ button.icon-button.ghost:hover:not([disabled]) {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 999px var(--accent-elevated),
|
inset 0 0 0 999px var(--accent-elevated),
|
||||||
0 6px 18px var(--accent-elevated-strong);
|
0 6px 18px var(--accent-elevated-strong);
|
||||||
filter: saturate(118%) brightness(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-pane--stack {
|
.preview-pane--stack {
|
||||||
min-height: 260px;
|
min-height: 460px;
|
||||||
|
position: relative;
|
||||||
|
--preview-nav-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.2em;
|
||||||
|
height: 2.2em;
|
||||||
|
padding: 0.45em;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button:hover:not([disabled]) {
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav-button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent, #2684ff);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav--overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) scale(var(--preview-nav-scale, 1));
|
||||||
|
margin-top: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane__nav--overlay .preview-pane__nav-button {
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: scale(calc(1 / var(--preview-nav-scale, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane--stack:hover .preview-pane__nav--overlay,
|
||||||
|
.skeuo-item__card:hover .preview-pane__nav--overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeuo-card__nav {
|
||||||
|
bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-tags {
|
.bulk-tags {
|
||||||
@@ -2010,3 +2088,7 @@ form.inline {
|
|||||||
width: 0.9rem;
|
width: 0.9rem;
|
||||||
height: 0.9rem;
|
height: 0.9rem;
|
||||||
}
|
}
|
||||||
|
.preview-pane__nav-button svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
IconChevronRight as TablerChevronRight,
|
IconChevronRight as TablerChevronRight,
|
||||||
IconDownload as TablerDownload,
|
IconDownload as TablerDownload,
|
||||||
IconFolderFilled,
|
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconTagFilled,
|
IconTagFilled,
|
||||||
IconUserFilled,
|
IconUserFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
IconLayoutList,
|
IconLayoutList,
|
||||||
IconLayoutGrid,
|
IconLayoutGrid,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import FolderSvg from '../assets/folder.svg';
|
||||||
|
|
||||||
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
const composeClassName = (base, extra) => (extra ? `${base} ${extra}` : base);
|
||||||
|
|
||||||
@@ -39,14 +41,21 @@ export const EditIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) =>
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const FolderIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
export const FolderIcon = ({ className, size = 16, title, ...rest }) => {
|
||||||
<IconFolderFilled
|
const dimensionProps = typeof size === 'number' ? { width: size, height: size } : {};
|
||||||
className={composeClassName('icon icon--fill', className)}
|
|
||||||
size={size}
|
return (
|
||||||
stroke={stroke}
|
<FolderSvg
|
||||||
|
className={composeClassName('folder-icon', className)}
|
||||||
|
{...dimensionProps}
|
||||||
|
role={title ? 'img' : 'presentation'}
|
||||||
|
aria-hidden={title ? undefined : true}
|
||||||
|
focusable="false"
|
||||||
|
title={title}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
|
||||||
<IconTagFilled
|
<IconTagFilled
|
||||||
@@ -93,6 +102,24 @@ export const ViewGridIcon = ({ className, size = '1em', stroke = 1.6, ...rest })
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ArrowLeftIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconArrowLeft
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowRightIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconArrowRight
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
ChevronIcon,
|
ChevronIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ module.exports = {
|
|||||||
test: /\.css$/i,
|
test: /\.css$/i,
|
||||||
use: ['style-loader', 'css-loader'],
|
use: ['style-loader', 'css-loader'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg$/i,
|
||||||
|
issuer: /\.[jt]sx?$/,
|
||||||
|
use: ['@svgr/webpack'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg$/i,
|
||||||
|
type: 'asset/resource',
|
||||||
|
issuer: { not: [/\.[jt]sx?$/] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user