Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17a9484ca | ||
|
|
9060905e7a | ||
|
|
159577dff9 | ||
|
|
86c75cea8e | ||
|
|
3f0c607383 |
@@ -0,0 +1,13 @@
|
||||
-- Restore width/height columns and repopulate from metadata where available.
|
||||
|
||||
ALTER TABLE document_assets
|
||||
ADD COLUMN width INTEGER,
|
||||
ADD COLUMN height INTEGER;
|
||||
|
||||
UPDATE document_assets
|
||||
SET width = (metadata->>'width')::INTEGER
|
||||
WHERE metadata ? 'width';
|
||||
|
||||
UPDATE document_assets
|
||||
SET height = (metadata->>'height')::INTEGER
|
||||
WHERE metadata ? 'height';
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Backfill existing width/height values into metadata then drop the columns.
|
||||
|
||||
UPDATE document_assets
|
||||
SET metadata = metadata || jsonb_build_object('width', width)
|
||||
WHERE width IS NOT NULL
|
||||
AND NOT (metadata ? 'width');
|
||||
|
||||
UPDATE document_assets
|
||||
SET metadata = metadata || jsonb_build_object('height', height)
|
||||
WHERE height IS NOT NULL
|
||||
AND NOT (metadata ? 'height');
|
||||
|
||||
ALTER TABLE document_assets
|
||||
DROP COLUMN width,
|
||||
DROP COLUMN height;
|
||||
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE documents ADD COLUMN current_version INT4;
|
||||
|
||||
UPDATE documents AS d
|
||||
SET current_version = dv.version_number
|
||||
FROM document_versions AS dv
|
||||
WHERE dv.id = d.current_version_id;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN current_version SET NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_documents_current_version_id;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
|
||||
|
||||
ALTER TABLE documents
|
||||
DROP COLUMN current_version_id;
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE documents ADD COLUMN current_version_id UUID;
|
||||
|
||||
UPDATE documents AS d
|
||||
SET current_version_id = dv.id
|
||||
FROM document_versions AS dv
|
||||
WHERE dv.document_id = d.id
|
||||
AND dv.version_number = d.current_version;
|
||||
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN current_version_id SET NOT NULL;
|
||||
|
||||
ALTER TABLE documents
|
||||
ADD CONSTRAINT documents_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES document_versions(id)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE INDEX idx_documents_current_version_id
|
||||
ON documents(current_version_id);
|
||||
|
||||
ALTER TABLE documents DROP COLUMN current_version;
|
||||
@@ -53,7 +53,7 @@ pub struct Document {
|
||||
pub original_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub current_version: i32,
|
||||
pub current_version_id: Uuid,
|
||||
pub uploaded_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub deleted_at: Option<NaiveDateTime>,
|
||||
@@ -70,7 +70,7 @@ pub struct NewDocument {
|
||||
pub original_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub current_version: i32,
|
||||
pub current_version_id: Uuid,
|
||||
pub metadata: serde_json::Value,
|
||||
pub issued_at: Option<NaiveDateTime>,
|
||||
pub title: String,
|
||||
@@ -111,8 +111,6 @@ pub struct DocumentAsset {
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub metadata: serde_json::Value,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
@@ -125,8 +123,6 @@ pub struct NewDocumentAsset {
|
||||
pub asset_type: String,
|
||||
pub s3_key: String,
|
||||
pub mime_type: String,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
|
||||
+143
-143
@@ -77,25 +77,6 @@ impl From<Tag> for TagResponse {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub original_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub current_version: i32,
|
||||
pub uploaded_at: String,
|
||||
pub updated_at: String,
|
||||
pub deleted_at: Option<String>,
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<TagResponse>,
|
||||
pub thumbnail: Option<DocumentAssetResponse>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DocumentVersionResponse {
|
||||
pub id: Uuid,
|
||||
@@ -112,17 +93,41 @@ pub struct DocumentAssetResponse {
|
||||
pub id: Uuid,
|
||||
pub asset_type: String,
|
||||
pub mime_type: String,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub url: String,
|
||||
pub metadata: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DocumentCurrentVersionResponse {
|
||||
#[serde(flatten)]
|
||||
pub version: DocumentVersionResponse,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<DocumentAssetResponse>,
|
||||
pub download_path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DocumentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub title: String,
|
||||
pub original_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub folder_id: Option<Uuid>,
|
||||
pub uploaded_at: String,
|
||||
pub updated_at: String,
|
||||
pub deleted_at: Option<String>,
|
||||
pub issued_at: Option<String>,
|
||||
pub metadata: Value,
|
||||
pub tags: Vec<TagResponse>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_version: Option<DocumentCurrentVersionResponse>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
pub struct DocumentDetailResponse {
|
||||
pub document: DocumentResponse,
|
||||
pub current_version: DocumentVersionResponse,
|
||||
pub assets: Vec<DocumentAssetResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -239,18 +244,18 @@ pub async fn list_documents(
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||
|
||||
let mut response = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
||||
let current_version = primary_versions.get(&doc.id).cloned();
|
||||
response.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags,
|
||||
thumbnail,
|
||||
current_version,
|
||||
)?);
|
||||
}
|
||||
|
||||
@@ -270,8 +275,7 @@ pub async fn get_document(
|
||||
}
|
||||
|
||||
let current_version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::version_number.eq(doc.current_version))
|
||||
.find(doc.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
||||
@@ -279,10 +283,7 @@ pub async fn get_document(
|
||||
drop(conn);
|
||||
|
||||
let assets = load_asset_responses(&state, version_id).await?;
|
||||
let thumbnail = assets
|
||||
.iter()
|
||||
.find(|asset| asset.asset_type == "thumbnail")
|
||||
.cloned();
|
||||
let version_response = to_version_response(current_version);
|
||||
|
||||
Ok(Json(DocumentDetailResponse {
|
||||
document: to_document_response(
|
||||
@@ -290,10 +291,8 @@ pub async fn get_document(
|
||||
user.user_id,
|
||||
doc,
|
||||
tags_map.get(&document_id).cloned(),
|
||||
thumbnail,
|
||||
Some((version_response, assets)),
|
||||
)?,
|
||||
current_version: to_version_response(current_version),
|
||||
assets,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -413,17 +412,12 @@ pub async fn request_document_assets(
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::version_number.eq(document.current_version))
|
||||
.first(&mut conn)?;
|
||||
|
||||
enqueue_job(
|
||||
&mut conn,
|
||||
JOB_ANALYZE_DOCUMENT,
|
||||
json!({
|
||||
"document_id": document_id,
|
||||
"document_version_id": version.id,
|
||||
"document_version_id": document.current_version_id,
|
||||
"force": query.force,
|
||||
}),
|
||||
None,
|
||||
@@ -439,11 +433,9 @@ pub async fn reanalyze_all_documents(
|
||||
) -> AppResult<(StatusCode, Json<BulkReanalyzeResponse>)> {
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let targets: Vec<(Uuid, Uuid)> = document_versions::table
|
||||
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
|
||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(document_versions::version_number.eq(documents::current_version))
|
||||
.select((documents::id, document_versions::id))
|
||||
.select((documents::id, documents::current_version_id))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut queued = 0usize;
|
||||
@@ -483,12 +475,10 @@ pub async fn reanalyze_selected_documents(
|
||||
|
||||
let mut conn = state.db()?;
|
||||
|
||||
let targets: Vec<(Uuid, Uuid)> = document_versions::table
|
||||
.inner_join(documents::table.on(document_versions::document_id.eq(documents::id)))
|
||||
let targets: Vec<(Uuid, Uuid)> = documents::table
|
||||
.filter(documents::id.eq_any(&document_ids))
|
||||
.filter(documents::deleted_at.is_null())
|
||||
.filter(document_versions::version_number.eq(documents::current_version))
|
||||
.select((documents::id, document_versions::id))
|
||||
.select((documents::id, documents::current_version_id))
|
||||
.load(&mut conn)?;
|
||||
|
||||
if targets.len() != document_ids.len() {
|
||||
@@ -526,16 +516,44 @@ pub async fn list_document_assets(
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::version_number.eq(document.current_version))
|
||||
.first(&mut conn)?;
|
||||
let version_id = document.current_version_id;
|
||||
drop(conn);
|
||||
|
||||
let assets = load_asset_responses(&state, version.id).await?;
|
||||
let assets = load_asset_responses(&state, version_id).await?;
|
||||
Ok(Json(assets))
|
||||
}
|
||||
|
||||
pub async fn get_document_asset(
|
||||
State(state): State<AppState>,
|
||||
Path((document_id, asset_id)): Path<(Uuid, Uuid)>,
|
||||
) -> AppResult<Json<DocumentAssetResponse>> {
|
||||
let mut conn = state.db()?;
|
||||
let document: Document = documents::table.find(document_id).first(&mut conn)?;
|
||||
if document.deleted_at.is_some() {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let asset: DocumentAsset = document_assets::table.find(asset_id).first(&mut conn)?;
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.find(asset.document_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
if version.document_id != document_id {
|
||||
return Err(AppError::not_found());
|
||||
}
|
||||
|
||||
let s3_key = asset.s3_key.clone();
|
||||
drop(conn);
|
||||
|
||||
let presigned_url = state
|
||||
.storage
|
||||
.presign_get_object(&s3_key, Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("failed to generate asset URL: {err}")))?;
|
||||
|
||||
Ok(Json(to_asset_response(asset, Some(presigned_url))))
|
||||
}
|
||||
|
||||
pub async fn download_document(
|
||||
State(state): State<AppState>,
|
||||
Path(document_id): Path<Uuid>,
|
||||
@@ -547,8 +565,7 @@ pub async fn download_document(
|
||||
}
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::version_number.eq(doc.current_version))
|
||||
.find(doc.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
let presigned_url = state
|
||||
@@ -586,8 +603,7 @@ pub async fn download_with_token(
|
||||
}
|
||||
|
||||
let version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(claims.doc_id))
|
||||
.filter(document_versions::version_number.eq(doc.current_version))
|
||||
.find(doc.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
let now = Utc::now().naive_utc();
|
||||
@@ -669,8 +685,7 @@ pub async fn update_document(
|
||||
}
|
||||
|
||||
let current_version: DocumentVersion = document_versions::table
|
||||
.filter(document_versions::document_id.eq(document_id))
|
||||
.filter(document_versions::version_number.eq(document.current_version))
|
||||
.find(document.current_version_id)
|
||||
.first(&mut conn)?;
|
||||
|
||||
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
|
||||
@@ -678,10 +693,7 @@ pub async fn update_document(
|
||||
drop(conn);
|
||||
|
||||
let assets = load_asset_responses(&state, version_id).await?;
|
||||
let thumbnail = assets
|
||||
.iter()
|
||||
.find(|asset| asset.asset_type == "thumbnail")
|
||||
.cloned();
|
||||
let version_response = to_version_response(current_version);
|
||||
|
||||
Ok(Json(DocumentDetailResponse {
|
||||
document: to_document_response(
|
||||
@@ -689,10 +701,8 @@ pub async fn update_document(
|
||||
user.user_id,
|
||||
document,
|
||||
tags_map.get(&document_id).cloned(),
|
||||
thumbnail,
|
||||
Some((version_response, assets)),
|
||||
)?,
|
||||
current_version: to_version_response(current_version),
|
||||
assets,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -943,9 +953,8 @@ async fn process_upload(
|
||||
|
||||
let existing = documents::table
|
||||
.inner_join(
|
||||
document_versions::table.on(document_versions::document_id
|
||||
.eq(documents::id)
|
||||
.and(document_versions::version_number.eq(documents::current_version))),
|
||||
document_versions::table
|
||||
.on(document_versions::id.eq(documents::current_version_id)),
|
||||
)
|
||||
.filter(document_versions::checksum.eq(&checksum_hex))
|
||||
.select((documents::all_columns, document_versions::all_columns))
|
||||
@@ -969,10 +978,7 @@ async fn process_upload(
|
||||
let tags = tags_map.get(&document.id).cloned();
|
||||
drop(conn);
|
||||
let assets = load_asset_responses(state, version.id).await?;
|
||||
let thumbnail = assets
|
||||
.iter()
|
||||
.find(|asset| asset.asset_type == "thumbnail")
|
||||
.cloned();
|
||||
let version_response = to_version_response(version.clone());
|
||||
|
||||
info!(
|
||||
document_id = %document.id,
|
||||
@@ -982,9 +988,13 @@ async fn process_upload(
|
||||
|
||||
return Ok(UploadOutcome {
|
||||
detail: DocumentDetailResponse {
|
||||
document: to_document_response(state, user_id, document, tags, thumbnail)?,
|
||||
current_version: to_version_response(version),
|
||||
assets,
|
||||
document: to_document_response(
|
||||
state,
|
||||
user_id,
|
||||
document,
|
||||
tags,
|
||||
Some((version_response, assets)),
|
||||
)?,
|
||||
},
|
||||
created: false,
|
||||
});
|
||||
@@ -1022,7 +1032,7 @@ async fn process_upload(
|
||||
original_name: original_name.clone(),
|
||||
content_type: content_type.clone(),
|
||||
folder_id,
|
||||
current_version: version_number,
|
||||
current_version_id: version_id,
|
||||
issued_at: None,
|
||||
title: derive_document_title(&original_name),
|
||||
metadata: metadata_value.clone(),
|
||||
@@ -1053,9 +1063,13 @@ async fn process_upload(
|
||||
};
|
||||
|
||||
let detail = DocumentDetailResponse {
|
||||
document: to_document_response(state, user_id, document, None, None)?,
|
||||
current_version: to_version_response(version.clone()),
|
||||
assets: Vec::new(),
|
||||
document: to_document_response(
|
||||
state,
|
||||
user_id,
|
||||
document,
|
||||
None,
|
||||
Some((to_version_response(version.clone()), Vec::new())),
|
||||
)?,
|
||||
};
|
||||
|
||||
if let Ok(mut conn) = state.db() {
|
||||
@@ -1112,75 +1126,64 @@ pub(crate) fn load_tags_for_documents(
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_primary_thumbnails(
|
||||
pub(crate) async fn load_primary_assets(
|
||||
state: &AppState,
|
||||
documents: &[Document],
|
||||
) -> AppResult<HashMap<Uuid, DocumentAssetResponse>> {
|
||||
) -> AppResult<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
|
||||
if documents.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let mut current_versions: HashMap<Uuid, i32> = HashMap::new();
|
||||
let mut doc_ids = Vec::with_capacity(documents.len());
|
||||
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
|
||||
let mut version_ids: Vec<Uuid> = Vec::with_capacity(documents.len());
|
||||
for doc in documents {
|
||||
current_versions.insert(doc.id, doc.current_version);
|
||||
doc_ids.push(doc.id);
|
||||
doc_to_version.insert(doc.id, doc.current_version_id);
|
||||
version_ids.push(doc.current_version_id);
|
||||
}
|
||||
|
||||
version_ids.sort();
|
||||
version_ids.dedup();
|
||||
|
||||
let mut conn = state.db()?;
|
||||
let versions: Vec<DocumentVersion> = document_versions::table
|
||||
.filter(document_versions::document_id.eq_any(&doc_ids))
|
||||
.filter(document_versions::id.eq_any(&version_ids))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut version_id_by_doc: HashMap<Uuid, Uuid> = HashMap::new();
|
||||
let mut doc_id_by_version: HashMap<Uuid, Uuid> = HashMap::new();
|
||||
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
|
||||
for version in versions {
|
||||
if let Some(current_number) = current_versions.get(&version.document_id) {
|
||||
if *current_number == version.version_number {
|
||||
version_id_by_doc.insert(version.document_id, version.id);
|
||||
doc_id_by_version.insert(version.id, version.document_id);
|
||||
version_map.insert(version.id, version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if version_id_by_doc.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let version_ids: Vec<Uuid> = version_id_by_doc.values().copied().collect();
|
||||
|
||||
let assets: Vec<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq_any(&version_ids))
|
||||
.filter(document_assets::asset_type.eq("thumbnail"))
|
||||
.order((
|
||||
document_assets::document_version_id.asc(),
|
||||
document_assets::created_at.asc(),
|
||||
))
|
||||
.load(&mut conn)?;
|
||||
|
||||
let mut assets_by_version: HashMap<Uuid, Vec<DocumentAssetResponse>> = HashMap::new();
|
||||
for asset in assets {
|
||||
let version_id = asset.document_version_id;
|
||||
let response = to_asset_response(asset, None);
|
||||
assets_by_version
|
||||
.entry(version_id)
|
||||
.or_default()
|
||||
.push(response);
|
||||
}
|
||||
|
||||
drop(conn);
|
||||
|
||||
let mut first_assets: HashMap<Uuid, DocumentAsset> = HashMap::new();
|
||||
for asset in assets {
|
||||
if let Some(doc_id) = doc_id_by_version.get(&asset.document_version_id) {
|
||||
first_assets.entry(*doc_id).or_insert(asset);
|
||||
let mut result: HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
|
||||
HashMap::with_capacity(doc_to_version.len());
|
||||
for (doc_id, version_id) in doc_to_version {
|
||||
if let Some(version) = version_map.remove(&version_id) {
|
||||
let assets = assets_by_version.remove(&version_id).unwrap_or_default();
|
||||
result.insert(doc_id, (to_version_response(version), assets));
|
||||
}
|
||||
}
|
||||
|
||||
let mut responses = HashMap::with_capacity(first_assets.len());
|
||||
for (doc_id, asset) in first_assets {
|
||||
let url = state
|
||||
.storage
|
||||
.presign_get_object(
|
||||
&asset.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
|
||||
responses.insert(doc_id, to_asset_response(asset, url));
|
||||
}
|
||||
|
||||
Ok(responses)
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn to_document_response(
|
||||
@@ -1188,9 +1191,18 @@ pub(crate) fn to_document_response(
|
||||
user_id: Uuid,
|
||||
doc: Document,
|
||||
tags: Option<Vec<Tag>>,
|
||||
thumbnail: Option<DocumentAssetResponse>,
|
||||
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
|
||||
) -> AppResult<DocumentResponse> {
|
||||
let current_version = if let Some((version, assets)) = current_version {
|
||||
let download_path = build_download_path(state, doc.id, user_id)?;
|
||||
Some(DocumentCurrentVersionResponse {
|
||||
version,
|
||||
assets,
|
||||
download_path,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(DocumentResponse {
|
||||
id: doc.id,
|
||||
@@ -1199,7 +1211,6 @@ pub(crate) fn to_document_response(
|
||||
original_name: doc.original_name,
|
||||
content_type: doc.content_type,
|
||||
folder_id: doc.folder_id,
|
||||
current_version: doc.current_version,
|
||||
uploaded_at: to_iso(doc.uploaded_at),
|
||||
updated_at: to_iso(doc.updated_at),
|
||||
deleted_at: doc.deleted_at.map(to_iso),
|
||||
@@ -1210,8 +1221,7 @@ pub(crate) fn to_document_response(
|
||||
.into_iter()
|
||||
.map(TagResponse::from)
|
||||
.collect(),
|
||||
thumbnail,
|
||||
download_path,
|
||||
current_version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1235,13 +1245,13 @@ fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse {
|
||||
}
|
||||
}
|
||||
|
||||
fn to_asset_response(asset: DocumentAsset, url: String) -> DocumentAssetResponse {
|
||||
fn to_asset_response(asset: DocumentAsset, url: Option<String>) -> DocumentAssetResponse {
|
||||
let metadata = asset.metadata.clone();
|
||||
DocumentAssetResponse {
|
||||
id: asset.id,
|
||||
asset_type: asset.asset_type,
|
||||
mime_type: asset.mime_type,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
metadata,
|
||||
url,
|
||||
created_at: to_iso(asset.created_at),
|
||||
}
|
||||
@@ -1274,20 +1284,10 @@ async fn load_asset_responses(
|
||||
.load(&mut conn)?;
|
||||
drop(conn);
|
||||
|
||||
let mut responses = Vec::with_capacity(assets.len());
|
||||
for asset in assets {
|
||||
let url = state
|
||||
.storage
|
||||
.presign_get_object(
|
||||
&asset.s3_key,
|
||||
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| AppError::internal(format!("failed to sign asset URL: {err}")))?;
|
||||
responses.push(to_asset_response(asset, url));
|
||||
}
|
||||
|
||||
Ok(responses)
|
||||
Ok(assets
|
||||
.into_iter()
|
||||
.map(|asset| to_asset_response(asset, None))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn to_iso(dt: NaiveDateTime) -> String {
|
||||
|
||||
@@ -19,8 +19,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::documents::{
|
||||
load_primary_thumbnails, load_tags_for_documents, to_document_response, to_iso,
|
||||
DocumentResponse,
|
||||
load_primary_assets, load_tags_for_documents, to_document_response, to_iso, DocumentResponse,
|
||||
};
|
||||
|
||||
const QUICKWIT_MAX_HITS: usize = 200;
|
||||
@@ -216,18 +215,18 @@ pub async fn list_folder_contents(
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||
|
||||
let mut documents = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
||||
let current_version = primary_versions.get(&doc.id).cloned();
|
||||
documents.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags,
|
||||
thumbnail,
|
||||
current_version,
|
||||
)?);
|
||||
}
|
||||
|
||||
@@ -400,17 +399,17 @@ pub async fn search_documents(
|
||||
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
|
||||
drop(conn);
|
||||
|
||||
let thumbnails = load_primary_thumbnails(&state, &docs).await?;
|
||||
let primary_versions = load_primary_assets(&state, &docs).await?;
|
||||
let mut response = Vec::with_capacity(doc_ids.len());
|
||||
for doc in docs {
|
||||
let tags = tags_map.get(&doc.id).cloned();
|
||||
let thumbnail = thumbnails.get(&doc.id).cloned();
|
||||
let current_version = primary_versions.get(&doc.id).cloned();
|
||||
response.push(to_document_response(
|
||||
&state,
|
||||
user.user_id,
|
||||
doc,
|
||||
tags,
|
||||
thumbnail,
|
||||
current_version,
|
||||
)?);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ pub fn create_router(state: AppState) -> Router<()> {
|
||||
.patch(documents::update_document),
|
||||
)
|
||||
.route("/:id/download", get(documents::download_document))
|
||||
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
|
||||
.route(
|
||||
"/:id/assets",
|
||||
get(documents::list_document_assets).post(documents::request_document_assets),
|
||||
|
||||
@@ -7,8 +7,6 @@ diesel::table! {
|
||||
asset_type -> Text,
|
||||
s3_key -> Text,
|
||||
mime_type -> Text,
|
||||
width -> Nullable<Int4>,
|
||||
height -> Nullable<Int4>,
|
||||
metadata -> Jsonb,
|
||||
created_at -> Timestamptz,
|
||||
}
|
||||
@@ -48,7 +46,6 @@ diesel::table! {
|
||||
#[max_length = 100]
|
||||
content_type -> Nullable<Varchar>,
|
||||
folder_id -> Nullable<Uuid>,
|
||||
current_version -> Int4,
|
||||
uploaded_at -> Timestamptz,
|
||||
updated_at -> Timestamptz,
|
||||
deleted_at -> Nullable<Timestamptz>,
|
||||
@@ -56,6 +53,7 @@ diesel::table! {
|
||||
issued_at -> Nullable<Timestamptz>,
|
||||
#[max_length = 255]
|
||||
title -> Varchar,
|
||||
current_version_id -> Uuid,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +126,6 @@ diesel::joinable!(document_assets -> document_versions (document_version_id));
|
||||
diesel::joinable!(document_tags -> documents (document_id));
|
||||
diesel::joinable!(document_tags -> tags (tag_id));
|
||||
diesel::joinable!(document_tags -> users (assigned_by));
|
||||
diesel::joinable!(document_versions -> documents (document_id));
|
||||
diesel::joinable!(documents -> folders (folder_id));
|
||||
diesel::joinable!(refresh_tokens -> users (user_id));
|
||||
|
||||
|
||||
@@ -377,8 +377,6 @@ fn persist_ocr_metadata(
|
||||
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
|
||||
s3_key: s3_key.to_string(),
|
||||
mime_type: "text/plain".to_string(),
|
||||
width: None,
|
||||
height: None,
|
||||
metadata: json!({
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
"source": source,
|
||||
|
||||
@@ -3,9 +3,7 @@ use std::{io::Cursor, panic, sync::Arc, time::Duration};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use diesel::{pg::upsert::excluded, prelude::*};
|
||||
use image::{
|
||||
codecs::png::PngEncoder, ColorType, GenericImageView, ImageEncoder, ImageFormat, ImageReader,
|
||||
};
|
||||
use image::{GenericImageView, ImageFormat, ImageReader};
|
||||
use pdfium_render::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -24,7 +22,10 @@ use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
|
||||
|
||||
const THUMBNAIL_WIDTH: u32 = 512;
|
||||
const THUMBNAIL_HEIGHT: u32 = 512;
|
||||
const PREVIEW_WIDTH: u32 = THUMBNAIL_WIDTH * 4;
|
||||
const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4;
|
||||
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
|
||||
const PREVIEW_ASSET_TYPE: &str = "preview";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThumbnailPayload {
|
||||
@@ -95,28 +96,61 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
}
|
||||
};
|
||||
|
||||
let generation = match generate_thumbnail(&initial.document, &bytes) {
|
||||
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
return JobExecution::Failed { error: err };
|
||||
}
|
||||
};
|
||||
|
||||
let asset_id = initial
|
||||
.existing_asset
|
||||
let thumbnail_asset_id = initial
|
||||
.existing_thumbnail
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
let s3_key = format!(
|
||||
let thumbnail_s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
initial.document.id, initial.version.version_number, THUMBNAIL_ASSET_TYPE, asset_id
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
THUMBNAIL_ASSET_TYPE,
|
||||
thumbnail_asset_id
|
||||
);
|
||||
|
||||
let preview_asset_id = initial
|
||||
.existing_preview
|
||||
.as_ref()
|
||||
.map(|asset| asset.id)
|
||||
.unwrap_or_else(Uuid::new_v4);
|
||||
let preview_s3_key = format!(
|
||||
"documents/{}/v{}/assets/{}/{}",
|
||||
initial.document.id,
|
||||
initial.version.version_number,
|
||||
PREVIEW_ASSET_TYPE,
|
||||
preview_asset_id
|
||||
);
|
||||
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&s3_key,
|
||||
generation.image_bytes.clone(),
|
||||
&preview_s3_key,
|
||||
generation.preview.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
|
||||
return JobExecution::Retry {
|
||||
delay: Duration::from_secs(30),
|
||||
error: err.to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(err) = state
|
||||
.storage
|
||||
.put_object(
|
||||
&thumbnail_s3_key,
|
||||
generation.thumbnail.image_bytes.clone(),
|
||||
Some("image/png".into()),
|
||||
None,
|
||||
)
|
||||
@@ -131,7 +165,24 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
|
||||
let state_clone = state.clone();
|
||||
match task::spawn_blocking(move || {
|
||||
persist_thumbnail_metadata(state_clone, &initial, &generation, asset_id, &s3_key)
|
||||
persist_assets_metadata(
|
||||
state_clone,
|
||||
&initial,
|
||||
&[
|
||||
AssetPersistence {
|
||||
asset_type: PREVIEW_ASSET_TYPE,
|
||||
asset_id: preview_asset_id,
|
||||
s3_key: &preview_s3_key,
|
||||
generated: &generation.preview,
|
||||
},
|
||||
AssetPersistence {
|
||||
asset_type: THUMBNAIL_ASSET_TYPE,
|
||||
asset_id: thumbnail_asset_id,
|
||||
s3_key: &thumbnail_s3_key,
|
||||
generated: &generation.thumbnail,
|
||||
},
|
||||
],
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
@@ -159,16 +210,29 @@ impl JobHandler for GenerateThumbnailsJob {
|
||||
struct ThumbnailContext {
|
||||
document: Document,
|
||||
version: DocumentVersion,
|
||||
existing_asset: Option<DocumentAsset>,
|
||||
existing_thumbnail: Option<DocumentAsset>,
|
||||
existing_preview: Option<DocumentAsset>,
|
||||
skip: bool,
|
||||
}
|
||||
|
||||
struct GeneratedThumbnail {
|
||||
struct GeneratedImage {
|
||||
image_bytes: Vec<u8>,
|
||||
width: Option<i32>,
|
||||
height: Option<i32>,
|
||||
}
|
||||
|
||||
struct GeneratedAssets {
|
||||
thumbnail: GeneratedImage,
|
||||
preview: GeneratedImage,
|
||||
}
|
||||
|
||||
struct AssetPersistence<'a> {
|
||||
asset_type: &'static str,
|
||||
asset_id: Uuid,
|
||||
s3_key: &'a str,
|
||||
generated: &'a GeneratedImage,
|
||||
}
|
||||
|
||||
fn load_thumbnail_context(
|
||||
state: Arc<AppState>,
|
||||
payload: &ThumbnailPayload,
|
||||
@@ -189,29 +253,45 @@ fn load_thumbnail_context(
|
||||
.first(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let existing: Option<DocumentAsset> = document_assets::table
|
||||
let existing_assets: Vec<DocumentAsset> = document_assets::table
|
||||
.filter(document_assets::document_version_id.eq(payload.document_version_id))
|
||||
.filter(document_assets::asset_type.eq(THUMBNAIL_ASSET_TYPE))
|
||||
.first(&mut conn)
|
||||
.optional()
|
||||
.filter(document_assets::asset_type.eq_any(vec![
|
||||
THUMBNAIL_ASSET_TYPE.to_string(),
|
||||
PREVIEW_ASSET_TYPE.to_string(),
|
||||
]))
|
||||
.load(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
let mut existing_thumbnail = None;
|
||||
let mut existing_preview = None;
|
||||
for asset in existing_assets {
|
||||
match asset.asset_type.as_str() {
|
||||
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
|
||||
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let (supported, _) = determine_thumbnail_support(&document);
|
||||
if !supported {
|
||||
return Err("thumbnail generation not supported for this document".into());
|
||||
}
|
||||
|
||||
let skip = existing.is_some() && !payload.force;
|
||||
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
|
||||
|
||||
Ok(ThumbnailContext {
|
||||
document,
|
||||
version,
|
||||
existing_asset: existing,
|
||||
existing_thumbnail,
|
||||
existing_preview,
|
||||
skip,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThumbnail, String> {
|
||||
fn generate_preview_and_thumbnail(
|
||||
document: &Document,
|
||||
bytes: &[u8],
|
||||
) -> Result<GeneratedAssets, String> {
|
||||
let is_pdf = document
|
||||
.content_type
|
||||
.as_deref()
|
||||
@@ -225,38 +305,41 @@ fn generate_thumbnail(document: &Document, bytes: &[u8]) -> Result<GeneratedThum
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
let (png_bytes, width, height) = if is_pdf {
|
||||
generate_pdf_thumbnail(bytes)?
|
||||
let (preview, thumbnail) = if is_pdf {
|
||||
generate_pdf_assets(bytes)?
|
||||
} else {
|
||||
generate_image_thumbnail(bytes)?
|
||||
generate_image_assets(bytes)?
|
||||
};
|
||||
|
||||
Ok(GeneratedThumbnail {
|
||||
image_bytes: png_bytes,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
Ok(GeneratedAssets { preview, thumbnail })
|
||||
}
|
||||
|
||||
fn generate_image_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> {
|
||||
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
||||
let reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|err| err.to_string())?;
|
||||
let mut image = reader.decode().map_err(|err| err.to_string())?;
|
||||
if image.width() > THUMBNAIL_WIDTH || image.height() > THUMBNAIL_HEIGHT {
|
||||
image = image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
|
||||
}
|
||||
let image = reader.decode().map_err(|err| err.to_string())?;
|
||||
|
||||
let (width, height) = image.dimensions();
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let buffer = cursor.into_inner();
|
||||
Ok((buffer, Some(width as i32), Some(height as i32)))
|
||||
let preview_image = if image.width() > PREVIEW_WIDTH || image.height() > PREVIEW_HEIGHT {
|
||||
image.thumbnail(PREVIEW_WIDTH, PREVIEW_HEIGHT)
|
||||
} else {
|
||||
image.clone()
|
||||
};
|
||||
|
||||
let thumbnail_image =
|
||||
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
||||
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
} else {
|
||||
preview_image.clone()
|
||||
};
|
||||
|
||||
let preview = encode_dynamic_image(preview_image)?;
|
||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||
|
||||
Ok((preview, thumbnail))
|
||||
}
|
||||
|
||||
fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<i32>), String> {
|
||||
fn generate_pdf_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
|
||||
let pdfium = panic::catch_unwind(|| Pdfium::default())
|
||||
.map_err(|_| "failed to initialize PDFium".to_string())?;
|
||||
|
||||
@@ -270,8 +353,8 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<
|
||||
.map_err(|err| format!("load first page: {err}"))?;
|
||||
|
||||
let render_config = PdfRenderConfig::new()
|
||||
.set_target_width(THUMBNAIL_WIDTH as i32)
|
||||
.set_maximum_height(THUMBNAIL_HEIGHT as i32)
|
||||
.set_target_width(PREVIEW_WIDTH as i32)
|
||||
.set_maximum_height(PREVIEW_HEIGHT as i32)
|
||||
.render_form_data(true)
|
||||
.rotate_if_landscape(PdfPageRenderRotation::None, true);
|
||||
|
||||
@@ -279,35 +362,53 @@ fn generate_pdf_thumbnail(bytes: &[u8]) -> Result<(Vec<u8>, Option<i32>, Option<
|
||||
.render_with_config(&render_config)
|
||||
.map_err(|err| format!("render pdf page: {err}"))?;
|
||||
|
||||
let image = bitmap.as_image().to_rgb8();
|
||||
let (width, height) = image.dimensions();
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
PngEncoder::new(&mut cursor)
|
||||
.write_image(image.as_raw(), width, height, ColorType::Rgb8.into())
|
||||
.map_err(|err| format!("encode pdf thumbnail: {err}"))?;
|
||||
let preview_buffer = bitmap.as_image().to_rgb8();
|
||||
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
|
||||
|
||||
Ok((cursor.into_inner(), Some(width as i32), Some(height as i32)))
|
||||
let thumbnail_image =
|
||||
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
|
||||
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
} else {
|
||||
preview_image.clone()
|
||||
};
|
||||
|
||||
let preview = encode_dynamic_image(preview_image)?;
|
||||
let thumbnail = encode_dynamic_image(thumbnail_image)?;
|
||||
|
||||
Ok((preview, thumbnail))
|
||||
}
|
||||
|
||||
fn persist_thumbnail_metadata(
|
||||
fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, String> {
|
||||
let (width, height) = image.dimensions();
|
||||
let mut cursor = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(GeneratedImage {
|
||||
image_bytes: cursor.into_inner(),
|
||||
width: Some(width as i32),
|
||||
height: Some(height as i32),
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_assets_metadata(
|
||||
state: Arc<AppState>,
|
||||
context: &ThumbnailContext,
|
||||
generated: &GeneratedThumbnail,
|
||||
asset_id: Uuid,
|
||||
s3_key: &str,
|
||||
assets: &[AssetPersistence<'_>],
|
||||
) -> Result<(), String> {
|
||||
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
for asset in assets {
|
||||
let new_asset = NewDocumentAsset {
|
||||
id: asset_id,
|
||||
id: asset.asset_id,
|
||||
document_version_id: context.version.id,
|
||||
asset_type: THUMBNAIL_ASSET_TYPE.to_string(),
|
||||
s3_key: s3_key.to_string(),
|
||||
asset_type: asset.asset_type.to_string(),
|
||||
s3_key: asset.s3_key.to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
width: generated.width,
|
||||
height: generated.height,
|
||||
metadata: json!({
|
||||
"generated_at": Utc::now().to_rfc3339(),
|
||||
"width": asset.generated.width,
|
||||
"height": asset.generated.height,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -321,12 +422,11 @@ fn persist_thumbnail_metadata(
|
||||
.set((
|
||||
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
|
||||
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
|
||||
document_assets::width.eq(excluded(document_assets::width)),
|
||||
document_assets::height.eq(excluded(document_assets::height)),
|
||||
document_assets::metadata.eq(excluded(document_assets::metadata)),
|
||||
))
|
||||
.execute(&mut conn)
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ use uuid::Uuid;
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentDetail {
|
||||
document: DocumentInfo,
|
||||
current_version: DocumentVersion,
|
||||
assets: Vec<DocumentAssetInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -18,11 +16,11 @@ struct DocumentInfo {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
original_name: String,
|
||||
current_version: i32,
|
||||
deleted_at: Option<String>,
|
||||
issued_at: Option<String>,
|
||||
tags: Vec<TagSummary>,
|
||||
download_path: String,
|
||||
#[serde(default)]
|
||||
current_version: Option<DocumentVersion>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -30,6 +28,10 @@ struct DocumentVersion {
|
||||
id: Uuid,
|
||||
s3_key: String,
|
||||
size_bytes: i64,
|
||||
version_number: i32,
|
||||
download_path: String,
|
||||
#[serde(default)]
|
||||
assets: Vec<DocumentAssetInfo>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -42,8 +44,8 @@ struct DocumentAssetInfo {
|
||||
#[derive(Deserialize)]
|
||||
struct DocumentListItem {
|
||||
id: Uuid,
|
||||
current_version: i32,
|
||||
download_path: String,
|
||||
#[serde(default)]
|
||||
current_version: Option<DocumentVersion>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -152,17 +154,22 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
|
||||
assert_eq!(detail.document.original_name, "doc.txt");
|
||||
assert_eq!(detail.document.title, "doc");
|
||||
assert_eq!(detail.document.current_version, 1);
|
||||
assert_eq!(detail.document.deleted_at, None);
|
||||
assert!(detail.document.issued_at.is_none());
|
||||
assert!(detail.document.tags.is_empty());
|
||||
assert!(detail.document.download_path.starts_with("/download/"));
|
||||
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
|
||||
assert!(detail.assets.is_empty());
|
||||
assert!(current_version.download_path.starts_with("/download/"));
|
||||
let current_version = detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("current version detail");
|
||||
assert_eq!(current_version.version_number, 1);
|
||||
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
|
||||
assert!(current_version.assets.is_empty());
|
||||
|
||||
let stored = app
|
||||
.storage()
|
||||
.get(&detail.current_version.s3_key)
|
||||
.get(¤t_version.s3_key)
|
||||
.await
|
||||
.expect("object stored");
|
||||
assert_eq!(stored.bytes, file_bytes);
|
||||
@@ -175,8 +182,18 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
assert_eq!(list.len(), 1);
|
||||
let item = list.pop().unwrap();
|
||||
assert_eq!(item.id, detail.document.id);
|
||||
assert_eq!(item.current_version, 1);
|
||||
assert!(item.download_path.starts_with("/download/"));
|
||||
assert_eq!(
|
||||
item.current_version
|
||||
.as_ref()
|
||||
.map(|version| version.version_number),
|
||||
Some(1)
|
||||
);
|
||||
assert!(item
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("list current version")
|
||||
.download_path
|
||||
.starts_with("/download/"));
|
||||
|
||||
let download = app
|
||||
.get(
|
||||
@@ -187,17 +204,17 @@ async fn upload_and_list_document() -> Result<()> {
|
||||
assert_eq!(download.status(), StatusCode::OK);
|
||||
let body = body_to_vec(download.into_body()).await?;
|
||||
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
||||
assert!(download_info.url.contains(&detail.current_version.s3_key));
|
||||
assert!(download_info.url.contains(¤t_version.s3_key));
|
||||
assert_eq!(download_info.filename, "doc.txt");
|
||||
|
||||
let redirect = app.get(&detail.document.download_path, None).await?;
|
||||
let redirect = app.get(¤t_version.download_path, None).await?;
|
||||
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
let location = redirect
|
||||
.headers()
|
||||
.get("location")
|
||||
.expect("redirect location header");
|
||||
let location = location.to_str().expect("location header utf8");
|
||||
assert!(location.contains(&detail.current_version.s3_key));
|
||||
assert!(location.contains(¤t_version.s3_key));
|
||||
|
||||
app.cleanup().await?;
|
||||
Ok(())
|
||||
@@ -243,7 +260,13 @@ async fn duplicate_and_restore_document() -> Result<()> {
|
||||
|
||||
assert_eq!(first_detail.document.id, second_detail.document.id);
|
||||
assert_eq!(second_detail.document.deleted_at, None);
|
||||
assert!(second_detail.assets.is_empty());
|
||||
assert!(second_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("second current version")
|
||||
.assets
|
||||
.is_empty());
|
||||
assert_eq!(app.storage().object_count().await, 1);
|
||||
|
||||
let delete = app
|
||||
@@ -341,8 +364,24 @@ async fn bulk_reanalyze_documents() -> Result<()> {
|
||||
}
|
||||
|
||||
let mut expected = vec![
|
||||
(first_detail.document.id, first_detail.current_version.id),
|
||||
(second_detail.document.id, second_detail.current_version.id),
|
||||
(
|
||||
first_detail.document.id,
|
||||
first_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("first current version")
|
||||
.id,
|
||||
),
|
||||
(
|
||||
second_detail.document.id,
|
||||
second_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("second current version")
|
||||
.id,
|
||||
),
|
||||
];
|
||||
payload_docs.sort();
|
||||
expected.sort();
|
||||
@@ -661,8 +700,24 @@ async fn bulk_reanalyze_selected_documents() -> Result<()> {
|
||||
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
|
||||
|
||||
let mut expected = vec![
|
||||
(first_detail.document.id, first_detail.current_version.id),
|
||||
(third_detail.document.id, third_detail.current_version.id),
|
||||
(
|
||||
first_detail.document.id,
|
||||
first_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("first current version")
|
||||
.id,
|
||||
),
|
||||
(
|
||||
third_detail.document.id,
|
||||
third_detail
|
||||
.document
|
||||
.current_version
|
||||
.as_ref()
|
||||
.expect("third current version")
|
||||
.id,
|
||||
),
|
||||
];
|
||||
payload_docs.sort();
|
||||
expected.sort();
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
export const getAssetFromGroup = (assets, assetType) => {
|
||||
if (!assetType || !assets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(assets)) {
|
||||
return assets.find((entry) => entry?.asset_type === assetType) || null;
|
||||
}
|
||||
|
||||
return assets?.[assetType] || null;
|
||||
};
|
||||
|
||||
export const getAssetFromVersion = (currentVersion, assetType) => {
|
||||
if (!currentVersion) {
|
||||
return null;
|
||||
}
|
||||
return getAssetFromGroup(currentVersion.assets, assetType);
|
||||
};
|
||||
|
||||
class AssetManager {
|
||||
constructor({ api, assetPresignTtlMs }) {
|
||||
this.api = api;
|
||||
this.assetPresignTtlMs = assetPresignTtlMs;
|
||||
this.assetCache = new Map();
|
||||
this.assetInflight = new Map();
|
||||
this.previewCache = new Map();
|
||||
this.previewInflight = new Map();
|
||||
}
|
||||
|
||||
setApi(api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
rememberAsset(entry) {
|
||||
if (entry?.id) {
|
||||
this.assetCache.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
hydrateAsset(asset) {
|
||||
if (!asset || !asset.id) {
|
||||
return asset;
|
||||
}
|
||||
const cached = this.assetCache.get(asset.id);
|
||||
if (!cached) {
|
||||
return asset;
|
||||
}
|
||||
const merged = { ...cached, ...asset };
|
||||
if (cached.url && !asset.url) {
|
||||
merged.url = cached.url;
|
||||
}
|
||||
if (cached.expiresAt) {
|
||||
const cachedExpires = Number(cached.expiresAt) || null;
|
||||
const assetExpires = Number(asset.expiresAt) || null;
|
||||
if (!assetExpires || (cachedExpires && cachedExpires > assetExpires)) {
|
||||
merged.expiresAt = cachedExpires;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
hydrateDocument(document) {
|
||||
if (!document) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const currentVersion = document.current_version || null;
|
||||
if (!currentVersion) {
|
||||
return document;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
let nextAssets = currentVersion.assets;
|
||||
|
||||
if (nextAssets && !Array.isArray(nextAssets)) {
|
||||
const hydrated = {};
|
||||
Object.keys(nextAssets).forEach((key) => {
|
||||
hydrated[key] = this.hydrateAsset(nextAssets[key]);
|
||||
if (hydrated[key] !== nextAssets[key]) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
nextAssets = { ...nextAssets, ...hydrated };
|
||||
}
|
||||
} else if (Array.isArray(nextAssets)) {
|
||||
const hydratedList = nextAssets.map((item) => this.hydrateAsset(item));
|
||||
if (
|
||||
hydratedList.length !== nextAssets.length ||
|
||||
hydratedList.some((item, index) => item !== nextAssets[index])
|
||||
) {
|
||||
changed = true;
|
||||
nextAssets = hydratedList;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return document;
|
||||
}
|
||||
|
||||
const nextCurrentVersion = { ...currentVersion, assets: nextAssets };
|
||||
return { ...document, current_version: nextCurrentVersion };
|
||||
}
|
||||
|
||||
hydrateDocuments(documents) {
|
||||
if (!Array.isArray(documents)) {
|
||||
return documents;
|
||||
}
|
||||
return documents.map((doc) => this.hydrateDocument(doc));
|
||||
}
|
||||
|
||||
hydrateDetail(detail) {
|
||||
if (!detail) {
|
||||
return detail;
|
||||
}
|
||||
let changed = false;
|
||||
const next = { ...detail };
|
||||
|
||||
if (detail.document) {
|
||||
const hydratedDocument = this.hydrateDocument(detail.document);
|
||||
if (hydratedDocument !== detail.document) {
|
||||
next.document = hydratedDocument;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(detail.assets)) {
|
||||
const hydratedAssets = detail.assets.map((item) => this.hydrateAsset(item));
|
||||
if (
|
||||
hydratedAssets.length !== detail.assets.length ||
|
||||
hydratedAssets.some((item, index) => item !== detail.assets[index])
|
||||
) {
|
||||
next.assets = hydratedAssets;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : detail;
|
||||
}
|
||||
|
||||
hydrateFolderContents(contents) {
|
||||
if (!contents) {
|
||||
return contents;
|
||||
}
|
||||
const next = { ...contents };
|
||||
if (Array.isArray(contents.documents)) {
|
||||
next.documents = this.hydrateDocuments(contents.documents);
|
||||
}
|
||||
if (contents.document) {
|
||||
next.document = this.hydrateDocument(contents.document);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
ensureAsset(documentId, asset, { force = false } = {}) {
|
||||
if (!documentId || !asset?.id) {
|
||||
return Promise.resolve(asset || null);
|
||||
}
|
||||
|
||||
const assetExpiresAt = typeof asset.expiresAt === 'number' ? asset.expiresAt : null;
|
||||
if (!force && asset?.url && (!assetExpiresAt || assetExpiresAt > Date.now())) {
|
||||
this.rememberAsset(asset);
|
||||
return Promise.resolve(asset);
|
||||
}
|
||||
|
||||
const cached = this.assetCache.get(asset.id);
|
||||
const now = Date.now();
|
||||
if (!force && cached && cached.expiresAt && cached.expiresAt > now && cached.url) {
|
||||
return Promise.resolve({ ...asset, ...cached });
|
||||
}
|
||||
|
||||
const inflightKey = `${documentId}:${asset.id}`;
|
||||
if (!force && this.assetInflight.has(inflightKey)) {
|
||||
return this.assetInflight.get(inflightKey);
|
||||
}
|
||||
|
||||
if (!this.api) {
|
||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||
}
|
||||
|
||||
const request = this.api
|
||||
.get(`/documents/${documentId}/assets/${asset.id}`)
|
||||
.then(({ data }) => {
|
||||
const entry = {
|
||||
...asset,
|
||||
...data,
|
||||
expiresAt: Date.now() + this.assetPresignTtlMs,
|
||||
};
|
||||
this.rememberAsset(entry);
|
||||
return entry;
|
||||
})
|
||||
.finally(() => {
|
||||
this.assetInflight.delete(inflightKey);
|
||||
});
|
||||
|
||||
this.assetInflight.set(inflightKey, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
ensurePreview(documentId, { force = false } = {}) {
|
||||
if (!documentId) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const cached = this.previewCache.get(documentId);
|
||||
const now = Date.now();
|
||||
if (!force && cached && (!cached.expiresAt || cached.expiresAt > now)) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
if (!force && this.previewInflight.has(documentId)) {
|
||||
return this.previewInflight.get(documentId);
|
||||
}
|
||||
|
||||
if (!this.api) {
|
||||
return Promise.reject(new Error('AssetManager API client is not configured.'));
|
||||
}
|
||||
|
||||
const request = this.api
|
||||
.get(`/documents/${documentId}/download`)
|
||||
.then(({ data }) => {
|
||||
const ttl = data.expires_in ? Math.max(data.expires_in - 60, 30) * 1000 : 5 * 60 * 1000;
|
||||
const entry = {
|
||||
url: data.url,
|
||||
contentType: data.content_type || null,
|
||||
filename: data.filename,
|
||||
expiresAt: Date.now() + ttl,
|
||||
};
|
||||
this.previewCache.set(documentId, entry);
|
||||
return entry;
|
||||
})
|
||||
.finally(() => {
|
||||
this.previewInflight.delete(documentId);
|
||||
});
|
||||
|
||||
this.previewInflight.set(documentId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
getPreview(documentId) {
|
||||
return this.previewCache.get(documentId) || null;
|
||||
}
|
||||
|
||||
deleteDocument(documentId) {
|
||||
if (!documentId) return;
|
||||
this.previewCache.delete(documentId);
|
||||
this.previewInflight.delete(documentId);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.assetCache.clear();
|
||||
this.assetInflight.clear();
|
||||
this.previewCache.clear();
|
||||
this.previewInflight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default AssetManager;
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const normalizeMessage = (error) => {
|
||||
if (!error) return 'Something went wrong.';
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
const { response, message } = error;
|
||||
if (response?.data?.error) return response.data.error;
|
||||
if (response?.data?.message) return response.data.message;
|
||||
return message || 'Something went wrong.';
|
||||
};
|
||||
|
||||
const useApiError = ({
|
||||
logger = console,
|
||||
onReport = noop,
|
||||
} = {}) => {
|
||||
return useCallback(
|
||||
(error, { message, variant = 'error', retry = null } = {}) => {
|
||||
const normalizedMessage = message || normalizeMessage(error);
|
||||
if (logger && typeof logger.error === 'function') {
|
||||
logger.error('[API]', normalizedMessage, error);
|
||||
}
|
||||
onReport({ message: normalizedMessage, variant, retry, error });
|
||||
return normalizedMessage;
|
||||
},
|
||||
[logger, onReport],
|
||||
);
|
||||
};
|
||||
|
||||
export default useApiError;
|
||||
+943
-156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
/* Skeuomorphic workspace styles */
|
||||
.skeuo-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skeuo-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
--desk-1: #f0e2c6;
|
||||
--desk-2: #e7d2ad;
|
||||
--desk-3: #d8b98a;
|
||||
--desk-grain-dark: rgba(110, 86, 48, 0.08);
|
||||
--desk-grain-fine: rgba(86, 66, 33, 0.04);
|
||||
--desk-vignette: rgba(60, 40, 18, 0.15);
|
||||
background:
|
||||
radial-gradient(120% 85% at 50% -10%, var(--desk-vignette), #0000 48%),
|
||||
linear-gradient(7deg, #0000 0 32%, var(--desk-grain-dark) 60%, #0000 85%),
|
||||
repeating-linear-gradient(
|
||||
7deg,
|
||||
var(--desk-grain-fine) 0 2px,
|
||||
#0000 2px 9px
|
||||
),
|
||||
linear-gradient(90deg, var(--desk-1), var(--desk-2) 48%, var(--desk-3) 100%);
|
||||
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%;
|
||||
background-blend-mode: multiply, overlay, normal, normal;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skeuo-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.skeuo-header__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-header__meta h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.skeuo-crumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.skeuo-crumb.is-current {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skeuo-crumb-separator {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skeuo-canvas {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin: 0 1.5rem 1.5rem;
|
||||
border-radius: 18px;
|
||||
background-image: radial-gradient(rgba(255, 255, 255, 0.5) 9%, transparent 9%),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.05), transparent 32%);
|
||||
background-size: 46px 46px, 100% 100%;
|
||||
box-shadow: inset 0 16px 28px rgba(0, 0, 0, 0.07), inset 0 -6px 20px rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.skeuo-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.skeuo-item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.28s ease, box-shadow 0.16s ease;
|
||||
outline: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.skeuo-item__body {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: width 0.28s ease, height 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-item:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.skeuo-item.is-dragging {
|
||||
cursor: grabbing;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.skeuo-item.is-zoomed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.skeuo-item.is-zoomed .skeuo-item__card {
|
||||
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-target .skeuo-item__card {
|
||||
outline: 1em dashed var(--accent);
|
||||
outline-offset: 1.41em;
|
||||
}
|
||||
|
||||
.skeuo-item.is-tag-pending .skeuo-item__card {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.skeuo-item.is-filtered-out {
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
filter: blur(15px) grayscale(100%);
|
||||
transition: opacity 0.6s ease, filter 0.28s ease;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
|
||||
.skeuo-item__tags {
|
||||
--tag-scale: 1;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
transform-origin: top right;
|
||||
transform: scale(var(--tag-scale)) translate(-0.5em, 0.5em);
|
||||
transition: transform 0.28s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.28rem 0.7rem;
|
||||
border-radius: 1px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0) 70%), var(--surface);
|
||||
color: var(--fg);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.18);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
transition: transform 0.16s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag.is-drag-hidden {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeuo-tag span {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skeuo-item__tags .skeuo-tag.is-tear-pending {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf {
|
||||
position: absolute;
|
||||
top: 2.25rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
align-items: flex-end;
|
||||
max-height: calc(100% - 4rem);
|
||||
overflow: auto;
|
||||
padding: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag.is-inactive {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.skeuo-tag-shelf .skeuo-tag.is-active {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.skeuo-tag-add {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: var(--fg);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-top: 0.4rem;
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.skeuo-tag-add:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.skeuo-tag-add:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.skeuo-tag-add:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
body.skeuo-cursor-remove,
|
||||
body.skeuo-cursor-remove * {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.skeuo-item__shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skeuo-item__card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeuo-item__card img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skeuo-item__placeholder {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
color: var(--muted);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -691,7 +691,7 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
.thumb-placeholder {
|
||||
width: 36px;
|
||||
height: 48px;
|
||||
border-radius: 0;
|
||||
border-radius: 1px;
|
||||
color: var(--muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -738,6 +738,11 @@ button.icon-button.ghost:hover:not([disabled]) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.folder.focused {
|
||||
box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45);
|
||||
background: rgba(43, 92, 255, 0.08);
|
||||
}
|
||||
|
||||
.documents-panel tbody tr.document {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ const env = dotenv.config({ path: path.resolve(__dirname, '.env.local') }).parse
|
||||
|
||||
const DEFAULT_DEV_API = 'http://127.0.0.1:3000';
|
||||
|
||||
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || '';
|
||||
const devServerApiTarget = API_BASE_URL || DEFAULT_DEV_API;
|
||||
const API_BASE_URL = env.API_BASE_URL || process.env.API_BASE_URL || DEFAULT_DEV_API;
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.jsx',
|
||||
@@ -49,13 +48,6 @@ module.exports = {
|
||||
port: 5173,
|
||||
historyApiFallback: true,
|
||||
open: true,
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api'],
|
||||
target: devServerApiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
devtool: 'source-map',
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user